agora inbox for [email protected]  
help / color / mirror / Atom feed
[PATCH v5 12/12] s/recommendable/recommended
486+ messages / 3 participants
[nested] [flat]

* [PATCH v5 12/12] s/recommendable/recommended
@ 2019-05-10 02:22  Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Justin Pryzby @ 2019-05-10 02:22 UTC (permalink / raw)

---
 doc/src/sgml/btree.sgml   | 2 +-
 doc/src/sgml/libpq.sgml   | 2 +-
 doc/src/sgml/runtime.sgml | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/doc/src/sgml/btree.sgml b/doc/src/sgml/btree.sgml
index 5881ea5..b0e0f08 100644
--- a/doc/src/sgml/btree.sgml
+++ b/doc/src/sgml/btree.sgml
@@ -60,7 +60,7 @@
   contain the single-type operators (and associated support functions)
   for its input data type, while cross-type comparison operators and
   support functions are <quote>loose</quote> in the family.  It is
-  recommendable that a complete set of cross-type operators be included
+  recommended that a complete set of cross-type operators be included
   in the family, thus ensuring that the planner can represent any
   comparison conditions that it deduces from transitivity.
  </para>
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index 8a8427f..4b031ff 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -7107,7 +7107,7 @@ int PQresultSetInstanceData(PGresult *res, PGEventProc proc, void *data);
        Beware that any storage represented by <parameter>data</parameter>
        will not be accounted for by <function>PQresultMemorySize</function>,
        unless it is allocated using <function>PQresultAlloc</function>.
-       (Doing so is recommendable because it eliminates the need to free
+       (Doing so is recommended because it eliminates the need to free
        such storage explicitly when the result is destroyed.)
       </para>
      </listitem>
diff --git a/doc/src/sgml/runtime.sgml b/doc/src/sgml/runtime.sgml
index ecdaafc..e3d0dec 100644
--- a/doc/src/sgml/runtime.sgml
+++ b/doc/src/sgml/runtime.sgml
@@ -111,7 +111,7 @@
    <command>initdb</command> will attempt to create the directory you
    specify if it does not already exist.  Of course, this will fail if
    <command>initdb</command> does not have permissions to write in the
-   parent directory.  It's generally recommendable that the
+   parent directory.  It's generally recommended that the
    <productname>PostgreSQL</productname> user own not just the data
    directory but its parent directory as well, so that this should not
    be a problem.  If the desired parent directory doesn't exist either,
-- 
2.7.4


--FkmkrVfFsRoUs1wW--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH v3 11/12] s/recommendable/recommended
@ 2019-05-10 02:22  Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Justin Pryzby @ 2019-05-10 02:22 UTC (permalink / raw)

---
 doc/src/sgml/btree.sgml   | 2 +-
 doc/src/sgml/libpq.sgml   | 2 +-
 doc/src/sgml/runtime.sgml | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/doc/src/sgml/btree.sgml b/doc/src/sgml/btree.sgml
index 996932e..283db7f 100644
--- a/doc/src/sgml/btree.sgml
+++ b/doc/src/sgml/btree.sgml
@@ -60,7 +60,7 @@
   contain the single-type operators (and associated support functions)
   for its input data type, while cross-type comparison operators and
   support functions are <quote>loose</quote> in the family.  It is
-  recommendable that a complete set of cross-type operators be included
+  recommended that a complete set of cross-type operators be included
   in the family, thus ensuring that the planner can represent any
   comparison conditions that it deduces from transitivity.
  </para>
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index 8a8427f..4b031ff 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -7107,7 +7107,7 @@ int PQresultSetInstanceData(PGresult *res, PGEventProc proc, void *data);
        Beware that any storage represented by <parameter>data</parameter>
        will not be accounted for by <function>PQresultMemorySize</function>,
        unless it is allocated using <function>PQresultAlloc</function>.
-       (Doing so is recommendable because it eliminates the need to free
+       (Doing so is recommended because it eliminates the need to free
        such storage explicitly when the result is destroyed.)
       </para>
      </listitem>
diff --git a/doc/src/sgml/runtime.sgml b/doc/src/sgml/runtime.sgml
index 798da30..21a7ce3 100644
--- a/doc/src/sgml/runtime.sgml
+++ b/doc/src/sgml/runtime.sgml
@@ -111,7 +111,7 @@
    <command>initdb</command> will attempt to create the directory you
    specify if it does not already exist.  Of course, this will fail if
    <command>initdb</command> does not have permissions to write in the
-   parent directory.  It's generally recommendable that the
+   parent directory.  It's generally recommended that the
    <productname>PostgreSQL</productname> user own not just the data
    directory but its parent directory as well, so that this should not
    be a problem.  If the desired parent directory doesn't exist either,
-- 
2.7.4


--cWoXeonUoKmBZSoM
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="v3-0012-Cleanup-remove-update-references-to-OID-column.patch"



^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH v6 4/7] Row pattern recognition patch (executor).
@ 2023-09-12 05:22  Tatsuo Ishii <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Tatsuo Ishii @ 2023-09-12 05:22 UTC (permalink / raw)

---
 src/backend/executor/nodeWindowAgg.c | 842 ++++++++++++++++++++++++++-
 src/backend/utils/adt/windowfuncs.c  |  37 +-
 src/include/catalog/pg_proc.dat      |   6 +
 src/include/nodes/execnodes.h        |  26 +
 4 files changed, 898 insertions(+), 13 deletions(-)

diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index 310ac23e3a..32270d051a 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -36,6 +36,7 @@
 #include "access/htup_details.h"
 #include "catalog/objectaccess.h"
 #include "catalog/pg_aggregate.h"
+#include "catalog/pg_collation_d.h"
 #include "catalog/pg_proc.h"
 #include "executor/executor.h"
 #include "executor/nodeWindowAgg.h"
@@ -48,6 +49,7 @@
 #include "utils/acl.h"
 #include "utils/builtins.h"
 #include "utils/datum.h"
+#include "utils/fmgroids.h"
 #include "utils/expandeddatum.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
@@ -182,8 +184,9 @@ static void begin_partition(WindowAggState *winstate);
 static void spool_tuples(WindowAggState *winstate, int64 pos);
 static void release_partition(WindowAggState *winstate);
 
-static int	row_is_in_frame(WindowAggState *winstate, int64 pos,
+static int  row_is_in_frame(WindowAggState *winstate, int64 pos,
 							TupleTableSlot *slot);
+
 static void update_frameheadpos(WindowAggState *winstate);
 static void update_frametailpos(WindowAggState *winstate);
 static void update_grouptailpos(WindowAggState *winstate);
@@ -195,9 +198,32 @@ static Datum GetAggInitVal(Datum textInitVal, Oid transtype);
 
 static bool are_peers(WindowAggState *winstate, TupleTableSlot *slot1,
 					  TupleTableSlot *slot2);
-static bool window_gettupleslot(WindowObject winobj, int64 pos,
-								TupleTableSlot *slot);
 
+static int	WinGetSlotInFrame(WindowObject winobj, TupleTableSlot *slot,
+							  int relpos, int seektype, bool set_mark,
+							  bool *isnull, bool *isout);
+static bool window_gettupleslot(WindowObject winobj, int64 pos, TupleTableSlot *slot);
+
+static void attno_map(Node *node);
+static bool attno_map_walker(Node *node, void *context);
+static int	row_is_in_reduced_frame(WindowObject winobj, int64 pos);
+static bool rpr_is_defined(WindowAggState *winstate);
+
+static void create_reduced_frame_map(WindowAggState *winstate);
+static int	get_reduced_frame_map(WindowAggState *winstate, int64 pos);
+static void register_reduced_frame_map(WindowAggState *winstate, int64 pos, int val);
+static void clear_reduced_frame_map(WindowAggState *winstate);
+static void update_reduced_frame(WindowObject winobj, int64 pos);
+
+static int64 evaluate_pattern(WindowObject winobj, int64 current_pos,
+							  char *vname, StringInfo encoded_str, bool *result);
+
+static bool get_slots(WindowObject winobj, int64 current_pos);
+
+static int	search_str_set(char *pattern, StringInfo *str_set, int set_size);
+static void search_str_set_recurse(char *pattern, StringInfo *str_set, int set_size, int set_index,
+								   char *encoded_str, int *resultlen);
+static char	pattern_initial(WindowAggState *winstate, char *vname);
 
 /*
  * initialize_windowaggregate
@@ -673,6 +699,7 @@ eval_windowaggregates(WindowAggState *winstate)
 	WindowObject agg_winobj;
 	TupleTableSlot *agg_row_slot;
 	TupleTableSlot *temp_slot;
+	bool		agg_result_isnull;
 
 	numaggs = winstate->numaggs;
 	if (numaggs == 0)
@@ -778,6 +805,9 @@ eval_windowaggregates(WindowAggState *winstate)
 	 * Note that we don't strictly need to restart in the last case, but if
 	 * we're going to remove all rows from the aggregation anyway, a restart
 	 * surely is faster.
+	 *
+	 *   - if RPR is enabled and skip mode is SKIP TO NEXT ROW,
+	 *     we restart aggregation too.
 	 *----------
 	 */
 	numaggs_restart = 0;
@@ -788,8 +818,11 @@ eval_windowaggregates(WindowAggState *winstate)
 			(winstate->aggregatedbase != winstate->frameheadpos &&
 			 !OidIsValid(peraggstate->invtransfn_oid)) ||
 			(winstate->frameOptions & FRAMEOPTION_EXCLUSION) ||
-			winstate->aggregatedupto <= winstate->frameheadpos)
+			winstate->aggregatedupto <= winstate->frameheadpos ||
+			(rpr_is_defined(winstate) &&
+			 winstate->rpSkipTo == ST_NEXT_ROW))
 		{
+			elog(DEBUG1, "peraggstate->restart is set");
 			peraggstate->restart = true;
 			numaggs_restart++;
 		}
@@ -861,8 +894,10 @@ eval_windowaggregates(WindowAggState *winstate)
 	 * If we created a mark pointer for aggregates, keep it pushed up to frame
 	 * head, so that tuplestore can discard unnecessary rows.
 	 */
+#ifdef NOT_USED
 	if (agg_winobj->markptr >= 0)
 		WinSetMarkPosition(agg_winobj, winstate->frameheadpos);
+#endif
 
 	/*
 	 * Now restart the aggregates that require it.
@@ -917,6 +952,29 @@ eval_windowaggregates(WindowAggState *winstate)
 	{
 		winstate->aggregatedupto = winstate->frameheadpos;
 		ExecClearTuple(agg_row_slot);
+
+		/*
+		 * If RPR is defined, we do not use aggregatedupto_nonrestarted.  To
+		 * avoid assertion failure below, we reset aggregatedupto_nonrestarted
+		 * to frameheadpos.
+		 */
+		if (rpr_is_defined(winstate))
+			aggregatedupto_nonrestarted = winstate->frameheadpos;
+	}
+
+	agg_result_isnull = false;
+	/* RPR is defined? */
+	if (rpr_is_defined(winstate))
+	{
+		/*
+		 * If the skip mode is SKIP TO PAST LAST ROW and we already know that
+		 * current row is a skipped row or an unmatched row, we don't need to
+		 * accumulate rows, just return NULL.
+		 */
+		if (winstate->rpSkipTo == ST_PAST_LAST_ROW &&
+			(get_reduced_frame_map(winstate, winstate->currentpos) == RF_SKIPPED ||
+			 get_reduced_frame_map(winstate, winstate->currentpos) == RF_UNMATCHED))
+			agg_result_isnull = true;
 	}
 
 	/*
@@ -930,6 +988,11 @@ eval_windowaggregates(WindowAggState *winstate)
 	{
 		int			ret;
 
+		elog(DEBUG1, "===== loop in frame starts: " INT64_FORMAT, winstate->aggregatedupto);
+
+		if (agg_result_isnull)
+			break;
+
 		/* Fetch next row if we didn't already */
 		if (TupIsNull(agg_row_slot))
 		{
@@ -945,9 +1008,28 @@ eval_windowaggregates(WindowAggState *winstate)
 		ret = row_is_in_frame(winstate, winstate->aggregatedupto, agg_row_slot);
 		if (ret < 0)
 			break;
+
 		if (ret == 0)
 			goto next_tuple;
 
+		if (rpr_is_defined(winstate))
+		{
+			/*
+			 * If the row status at currentpos is already decided and current
+			 * row status is not decided yet, it means we passed the last
+			 * reduced frame. Time to break the loop.
+			 */
+			if (get_reduced_frame_map(winstate, winstate->currentpos) != RF_NOT_DETERMINED &&
+				get_reduced_frame_map(winstate, winstate->aggregatedupto) == RF_NOT_DETERMINED)
+				break;
+			/*
+			 * Otherwise we need to calculate the reduced frame.
+			 */
+			ret = row_is_in_reduced_frame(winstate->agg_winobj, winstate->aggregatedupto);
+			if (ret == -1)	/* unmatched row */
+				break;
+		}
+
 		/* Set tuple context for evaluation of aggregate arguments */
 		winstate->tmpcontext->ecxt_outertuple = agg_row_slot;
 
@@ -976,6 +1058,7 @@ next_tuple:
 		ExecClearTuple(agg_row_slot);
 	}
 
+
 	/* The frame's end is not supposed to move backwards, ever */
 	Assert(aggregatedupto_nonrestarted <= winstate->aggregatedupto);
 
@@ -996,6 +1079,16 @@ next_tuple:
 								 peraggstate,
 								 result, isnull);
 
+		/*
+		 * RPR is enabled and we just return NULL. because skip mode is SKIP
+		 * TO PAST LAST ROW and current row is skipped row or unmatched row.
+		 */
+		if (agg_result_isnull)
+		{
+			*isnull = true;
+			*result = (Datum) 0;
+		}
+
 		/*
 		 * save the result in case next row shares the same frame.
 		 *
@@ -1090,6 +1183,7 @@ begin_partition(WindowAggState *winstate)
 	winstate->framehead_valid = false;
 	winstate->frametail_valid = false;
 	winstate->grouptail_valid = false;
+	create_reduced_frame_map(winstate);
 	winstate->spooled_rows = 0;
 	winstate->currentpos = 0;
 	winstate->frameheadpos = 0;
@@ -2053,6 +2147,8 @@ ExecWindowAgg(PlanState *pstate)
 
 	CHECK_FOR_INTERRUPTS();
 
+	elog(DEBUG1, "ExecWindowAgg called. pos: " INT64_FORMAT , winstate->currentpos);
+
 	if (winstate->status == WINDOWAGG_DONE)
 		return NULL;
 
@@ -2221,6 +2317,17 @@ ExecWindowAgg(PlanState *pstate)
 		/* don't evaluate the window functions when we're in pass-through mode */
 		if (winstate->status == WINDOWAGG_RUN)
 		{
+			/*
+			 * If RPR is defined and skip mode is next row, we need to clear existing
+			 * reduced frame info so that we newly calculate the info starting from
+			 * current row.
+			 */
+			if (rpr_is_defined(winstate))
+			{
+				if (winstate->rpSkipTo == ST_NEXT_ROW)
+					clear_reduced_frame_map(winstate);
+			}
+
 			/*
 			 * Evaluate true window functions
 			 */
@@ -2388,6 +2495,9 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 	TupleDesc	scanDesc;
 	ListCell   *l;
 
+	TargetEntry	*te;
+	Expr		*expr;
+
 	/* check for unsupported flags */
 	Assert(!(eflags & (EXEC_FLAG_BACKWARD | EXEC_FLAG_MARK)));
 
@@ -2483,6 +2593,16 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 	winstate->temp_slot_2 = ExecInitExtraTupleSlot(estate, scanDesc,
 												   &TTSOpsMinimalTuple);
 
+	winstate->prev_slot = ExecInitExtraTupleSlot(estate, scanDesc,
+												 &TTSOpsMinimalTuple);
+
+	winstate->next_slot = ExecInitExtraTupleSlot(estate, scanDesc,
+												 &TTSOpsMinimalTuple);
+
+	winstate->null_slot = ExecInitExtraTupleSlot(estate, scanDesc,
+												 &TTSOpsMinimalTuple);
+	winstate->null_slot = ExecStoreAllNullTuple(winstate->null_slot);
+
 	/*
 	 * create frame head and tail slots only if needed (must create slots in
 	 * exactly the same cases that update_frameheadpos and update_frametailpos
@@ -2667,6 +2787,39 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 	winstate->inRangeAsc = node->inRangeAsc;
 	winstate->inRangeNullsFirst = node->inRangeNullsFirst;
 
+	/* Set up SKIP TO type */
+	winstate->rpSkipTo = node->rpSkipTo;
+	/* Set up row pattern recognition PATTERN clause */
+	winstate->patternVariableList = node->patternVariable;
+	winstate->patternRegexpList = node->patternRegexp;
+
+	/* Set up row pattern recognition DEFINE clause */
+	winstate->defineInitial = node->defineInitial;
+	winstate->defineVariableList = NIL;
+	winstate->defineClauseList = NIL;
+	if (node->defineClause != NIL)
+	{
+		/*
+		 * Tweak arg var of PREV/NEXT so that it refers to scan/inner slot.
+		 */
+		foreach(l, node->defineClause)
+		{
+			char		*name;
+			ExprState	*exps;
+
+			te = lfirst(l);
+			name = te->resname;
+			expr = te->expr;
+
+			elog(DEBUG1, "defineVariable name: %s", name);
+			winstate->defineVariableList = lappend(winstate->defineVariableList,
+												   makeString(pstrdup(name)));
+			attno_map((Node *)expr);
+			exps = ExecInitExpr(expr, (PlanState *) winstate);
+			winstate->defineClauseList = lappend(winstate->defineClauseList, exps);
+		}
+	}
+
 	winstate->all_first = true;
 	winstate->partition_spooled = false;
 	winstate->more_partitions = false;
@@ -2674,6 +2827,57 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 	return winstate;
 }
 
+/*
+ * Rewrite varno of Var node that is the argument of PREV/NET so that it sees
+ * scan tuple (PREV) or inner tuple (NEXT).
+ */
+static void
+attno_map(Node *node)
+{
+	(void) expression_tree_walker(node, attno_map_walker, NULL);
+}
+
+static bool
+attno_map_walker(Node *node, void *context)
+{
+	FuncExpr	*func;
+	int			nargs;
+	Expr		*expr;
+	Var			*var;
+
+	if (node == NULL)
+		return false;
+
+	if (IsA(node, FuncExpr))
+	{
+		func = (FuncExpr *)node;
+
+		if (func->funcid == F_PREV || func->funcid == F_NEXT)
+		{
+			/* sanity check */
+			nargs = list_length(func->args);
+			if (list_length(func->args) != 1)
+				elog(ERROR, "PREV/NEXT must have 1 argument but function %d has %d args", func->funcid, nargs);
+
+			expr = (Expr *) lfirst(list_head(func->args));
+			if (!IsA(expr, Var))
+				elog(ERROR, "PREV/NEXT's arg is not Var");	/* XXX: is it possible that arg type is Const? */
+			var = (Var *)expr;
+
+			if (func->funcid == F_PREV)
+				/*
+				 * Rewrite varno from OUTER_VAR to regular var no so that the
+				 * var references scan tuple.
+				 */
+				var->varno = var->varnosyn;
+			else
+				var->varno = INNER_VAR;
+			elog(DEBUG1, "PREV/NEXT's varno is rewritten to: %d", var->varno);
+		}
+	}
+	return expression_tree_walker(node, attno_map_walker, NULL);
+}
+
 /* -----------------
  * ExecEndWindowAgg
  * -----------------
@@ -2691,6 +2895,8 @@ ExecEndWindowAgg(WindowAggState *node)
 	ExecClearTuple(node->agg_row_slot);
 	ExecClearTuple(node->temp_slot_1);
 	ExecClearTuple(node->temp_slot_2);
+	ExecClearTuple(node->prev_slot);
+	ExecClearTuple(node->next_slot);
 	if (node->framehead_slot)
 		ExecClearTuple(node->framehead_slot);
 	if (node->frametail_slot)
@@ -2740,6 +2946,8 @@ ExecReScanWindowAgg(WindowAggState *node)
 	ExecClearTuple(node->agg_row_slot);
 	ExecClearTuple(node->temp_slot_1);
 	ExecClearTuple(node->temp_slot_2);
+	ExecClearTuple(node->prev_slot);
+	ExecClearTuple(node->next_slot);
 	if (node->framehead_slot)
 		ExecClearTuple(node->framehead_slot);
 	if (node->frametail_slot)
@@ -3100,7 +3308,7 @@ window_gettupleslot(WindowObject winobj, int64 pos, TupleTableSlot *slot)
 		return false;
 
 	if (pos < winobj->markpos)
-		elog(ERROR, "cannot fetch row before WindowObject's mark position");
+		elog(ERROR, "cannot fetch row: " INT64_FORMAT " before WindowObject's mark position: " INT64_FORMAT,  pos, winobj->markpos );
 
 	oldcontext = MemoryContextSwitchTo(winstate->ss.ps.ps_ExprContext->ecxt_per_query_memory);
 
@@ -3420,14 +3628,54 @@ WinGetFuncArgInFrame(WindowObject winobj, int argno,
 	WindowAggState *winstate;
 	ExprContext *econtext;
 	TupleTableSlot *slot;
-	int64		abs_pos;
-	int64		mark_pos;
 
 	Assert(WindowObjectIsValid(winobj));
 	winstate = winobj->winstate;
 	econtext = winstate->ss.ps.ps_ExprContext;
 	slot = winstate->temp_slot_1;
 
+	if (WinGetSlotInFrame(winobj, slot,
+						  relpos, seektype, set_mark,
+						  isnull, isout) == 0)
+	{
+		econtext->ecxt_outertuple = slot;
+		return ExecEvalExpr((ExprState *) list_nth(winobj->argstates, argno),
+							econtext, isnull);
+	}
+
+	if (isout)
+		*isout = true;
+	*isnull = true;
+	return (Datum) 0;
+}
+
+/*
+ * WinGetSlotInFrame
+ * slot: TupleTableSlot to store the result
+ * relpos: signed rowcount offset from the seek position
+ * seektype: WINDOW_SEEK_HEAD or WINDOW_SEEK_TAIL
+ * set_mark: If the row is found/in frame and set_mark is true, the mark is
+ *		moved to the row as a side-effect.
+ * isnull: output argument, receives isnull status of result
+ * isout: output argument, set to indicate whether target row position
+ *		is out of frame (can pass NULL if caller doesn't care about this)
+ *
+ * Returns 0 if we successfullt got the slot. false if out of frame.
+ * (also isout is set)
+ */
+static int
+WinGetSlotInFrame(WindowObject winobj, TupleTableSlot *slot,
+					 int relpos, int seektype, bool set_mark,
+					 bool *isnull, bool *isout)
+{
+	WindowAggState *winstate;
+	int64		abs_pos;
+	int64		mark_pos;
+	int			num_reduced_frame;
+
+	Assert(WindowObjectIsValid(winobj));
+	winstate = winobj->winstate;
+
 	switch (seektype)
 	{
 		case WINDOW_SEEK_CURRENT:
@@ -3494,11 +3742,21 @@ WinGetFuncArgInFrame(WindowObject winobj, int argno,
 						 winstate->frameOptions);
 					break;
 			}
+			num_reduced_frame = row_is_in_reduced_frame(winobj, winstate->frameheadpos);
+			if (num_reduced_frame < 0)
+				goto out_of_frame;
+			else if (num_reduced_frame > 0)
+				if (relpos >= num_reduced_frame)
+					goto out_of_frame;
 			break;
 		case WINDOW_SEEK_TAIL:
 			/* rejecting relpos > 0 is easy and simplifies code below */
 			if (relpos > 0)
 				goto out_of_frame;
+
+			/* RPR cares about frame head pos. Need to call update_frameheadpos */
+			update_frameheadpos(winstate);
+
 			update_frametailpos(winstate);
 			abs_pos = winstate->frametailpos - 1 + relpos;
 
@@ -3565,6 +3823,12 @@ WinGetFuncArgInFrame(WindowObject winobj, int argno,
 					mark_pos = 0;	/* keep compiler quiet */
 					break;
 			}
+
+			num_reduced_frame = row_is_in_reduced_frame(winobj, winstate->frameheadpos + relpos);
+			if (num_reduced_frame < 0)
+				goto out_of_frame;
+			else if (num_reduced_frame > 0)
+				abs_pos = winstate->frameheadpos + relpos + num_reduced_frame - 1;
 			break;
 		default:
 			elog(ERROR, "unrecognized window seek type: %d", seektype);
@@ -3583,15 +3847,13 @@ WinGetFuncArgInFrame(WindowObject winobj, int argno,
 		*isout = false;
 	if (set_mark)
 		WinSetMarkPosition(winobj, mark_pos);
-	econtext->ecxt_outertuple = slot;
-	return ExecEvalExpr((ExprState *) list_nth(winobj->argstates, argno),
-						econtext, isnull);
+	return 0;
 
 out_of_frame:
 	if (isout)
 		*isout = true;
 	*isnull = true;
-	return (Datum) 0;
+	return -1;
 }
 
 /*
@@ -3622,3 +3884,561 @@ WinGetFuncArgCurrent(WindowObject winobj, int argno, bool *isnull)
 	return ExecEvalExpr((ExprState *) list_nth(winobj->argstates, argno),
 						econtext, isnull);
 }
+
+/*
+ * rpr_is_defined
+ * return true if Row pattern recognition is defined.
+ */
+static
+bool rpr_is_defined(WindowAggState *winstate)
+{
+	return winstate->patternVariableList != NIL;
+}
+
+/*
+ * row_is_in_reduced_frame
+ * Determine whether a row is in the current row's reduced window frame according
+ * to row pattern matching
+ *
+ * The row must has been already determined that it is in a full window frame
+ * and fetched it into slot.
+ *
+ * Returns:
+ * = 0, RPR is not defined.
+ * >0, if the row is the first in the reduced frame. Return the number of rows in the reduced frame.
+ * -1, if the row is unmatched row
+ * -2, if the row is in the reduced frame but needed to be skipped because of
+ * AFTER MATCH SKIP PAST LAST ROW
+ */
+static
+int row_is_in_reduced_frame(WindowObject winobj, int64 pos)
+{
+	WindowAggState *winstate = winobj->winstate;
+	int		state;
+	int		rtn;
+
+	if (!rpr_is_defined(winstate))
+	{
+		/*
+		 * RPR is not defined. Assume that we are always in the the reduced
+		 * window frame.
+		 */
+		rtn = 0;
+		elog(DEBUG1, "row_is_in_reduced_frame returns %d: pos: " INT64_FORMAT, rtn, pos);
+		return rtn;
+	}
+
+	state = get_reduced_frame_map(winstate, pos);
+
+	if (state == RF_NOT_DETERMINED)
+	{
+		update_frameheadpos(winstate);
+		update_reduced_frame(winobj, pos);
+	}
+
+	state = get_reduced_frame_map(winstate, pos);
+
+	switch (state)
+	{
+		int64	i;
+		int		num_reduced_rows;
+
+		case RF_FRAME_HEAD:
+			num_reduced_rows = 1;
+			for (i = pos + 1; get_reduced_frame_map(winstate,i) == RF_SKIPPED; i++)
+				num_reduced_rows++;
+			rtn = num_reduced_rows;
+			break;
+
+		case RF_SKIPPED:
+			rtn = -2;
+			break;
+
+		case RF_UNMATCHED:
+			rtn = -1;
+			break;
+
+		default:
+			elog(ERROR, "Unrecognized state: %d at: " INT64_FORMAT, state, pos);
+			break;
+	}
+
+	elog(DEBUG1, "row_is_in_reduced_frame returns %d: pos: " INT64_FORMAT, rtn, pos);
+	return rtn;
+}
+
+#define REDUCED_FRAME_MAP_INIT_SIZE	1024L
+
+/*
+ * Create reduced frame map
+ */
+static
+void create_reduced_frame_map(WindowAggState *winstate)
+{
+	winstate->reduced_frame_map =
+		MemoryContextAlloc(winstate->partcontext, REDUCED_FRAME_MAP_INIT_SIZE);
+	winstate->alloc_sz = REDUCED_FRAME_MAP_INIT_SIZE;
+	clear_reduced_frame_map(winstate);
+}
+
+/*
+ * Clear reduced frame map
+ */
+static
+void clear_reduced_frame_map(WindowAggState *winstate)
+{
+	Assert(winstate->reduced_frame_map != NULL);
+	MemSet(winstate->reduced_frame_map, RF_NOT_DETERMINED,
+		   winstate->alloc_sz);
+}
+
+/*
+ * Get reduced frame map specified by pos
+ */
+static
+int get_reduced_frame_map(WindowAggState *winstate, int64 pos)
+{
+	Assert(winstate->reduced_frame_map != NULL);
+
+	if (pos < 0 || pos >= winstate->alloc_sz)
+		elog(ERROR, "wrong pos: " INT64_FORMAT, pos);
+
+	return winstate->reduced_frame_map[pos];
+}
+
+/*
+ * Add/replace reduced frame map member at pos.
+ * If there's no enough space, expand the map.
+ */
+static
+void register_reduced_frame_map(WindowAggState *winstate, int64 pos, int val)
+{
+	int64	realloc_sz;
+
+	Assert(winstate->reduced_frame_map != NULL);
+
+	if (pos < 0)
+		elog(ERROR, "wrong pos: " INT64_FORMAT, pos);
+
+	if (pos > winstate->alloc_sz - 1)
+	{
+		realloc_sz = winstate->alloc_sz * 2;
+
+		winstate->reduced_frame_map =
+			repalloc(winstate->reduced_frame_map, realloc_sz);
+
+		MemSet(winstate->reduced_frame_map + winstate->alloc_sz,
+			   RF_NOT_DETERMINED, realloc_sz - winstate->alloc_sz);
+
+		winstate->alloc_sz = realloc_sz;
+	}
+
+	winstate->reduced_frame_map[pos] = val;
+}
+
+/*
+ * update_reduced_frame
+ *		Update reduced frame info.
+ */
+static
+void update_reduced_frame(WindowObject winobj, int64 pos)
+{
+	WindowAggState *winstate = winobj->winstate;
+	ListCell	*lc1, *lc2;
+	bool		expression_result;
+	int			num_matched_rows;
+	int64		original_pos;
+	bool		anymatch;
+	StringInfo	encoded_str;
+	StringInfo	pattern_str = makeStringInfo();
+
+	/*
+	 * Array of pattern variables evaluted to true.
+	 * Each character corresponds to pattern variable.
+	 * Example:
+	 * str_set[0] = "AB";
+	 * str_set[1] = "AC";
+	 * In this case at row 0 A and B are true, and A and C are true in row 1.
+	 */
+	#define ENCODED_STR_ARRAY_ALLOC_SIZE	128
+	StringInfo	*str_set = NULL;
+	int		str_set_index;
+	int		str_set_size;
+
+	/* save original pos */
+	original_pos = pos;
+
+	/*
+	 * Loop over until none of pattern matches or encounters end of frame.
+	 */
+	for (;;)
+	{
+		int64	result_pos = -1;
+
+		/*
+		 * Loop over each PATTERN variable.
+		 */
+		anymatch = false;
+		encoded_str = makeStringInfo();
+
+		forboth(lc1, winstate->patternVariableList, lc2, winstate->patternRegexpList)
+		{
+			char	*vname = strVal(lfirst(lc1));
+			char	*quantifier = strVal(lfirst(lc2));
+
+			elog(DEBUG1, "pos: " INT64_FORMAT " pattern vname: %s quantifier: %s", pos, vname, quantifier);
+
+			expression_result = false;
+
+			/* evaluate row pattern against current row */
+			result_pos = evaluate_pattern(winobj, pos, vname, encoded_str, &expression_result);
+			if (expression_result)
+			{
+				elog(DEBUG1, "expression result is true");
+				anymatch = true;
+			}
+
+			/*
+			 * If out of frame, we are done.
+			 */
+			 if (result_pos < 0)
+				 break;
+		}
+
+		if (!anymatch)
+		{
+			/* none of patterns matched. */
+			break;
+		}
+
+		/* build encoded string array */
+		if (str_set == NULL)
+		{
+			str_set_index = 0;
+			str_set_size = ENCODED_STR_ARRAY_ALLOC_SIZE * sizeof(StringInfo);
+			str_set = palloc(str_set_size);
+		}
+
+		str_set[str_set_index++] = encoded_str;
+
+		elog(DEBUG1, "pos: " INT64_FORMAT " str_set_index: %d encoded_str: %s", pos, str_set_index, encoded_str->data);
+
+		if (str_set_index >= str_set_size)
+		{
+			str_set_size *= 2;
+			str_set = repalloc(str_set, str_set_size);
+		}
+
+		/* move to next row */
+		pos++;
+
+		if (result_pos < 0)
+		{
+			/* out of frame */
+			break;
+		}
+	}
+
+	if (str_set == NULL)
+	{
+		/* no match found in the first row */
+		register_reduced_frame_map(winstate, original_pos, RF_UNMATCHED);
+		return;
+	}
+
+	elog(DEBUG2, "pos: " INT64_FORMAT " encoded_str: %s", pos, encoded_str->data);
+
+	/* build regular expression */
+	pattern_str = makeStringInfo();
+	appendStringInfoChar(pattern_str, '^');
+	forboth (lc1, winstate->patternVariableList, lc2, winstate->patternRegexpList)
+	{
+		char	*vname = strVal(lfirst(lc1));
+		char	*quantifier = strVal(lfirst(lc2));
+		char	 initial;
+
+		initial = pattern_initial(winstate, vname);
+		Assert(initial != 0);
+		appendStringInfoChar(pattern_str, initial);
+		if (quantifier[0])
+			appendStringInfoChar(pattern_str, quantifier[0]);
+		elog(DEBUG1, "vname: %s initial: %c quantifier: %s", vname, initial, quantifier);
+	}
+
+	elog(DEBUG2, "pos: " INT64_FORMAT " pattern: %s", pos, pattern_str->data);
+
+	/* look for matching pattern variable sequence */
+	num_matched_rows = search_str_set(pattern_str->data, str_set, str_set_index);
+	/*
+	 * We are at the first row in the reduced frame.  Save the number of
+	 * matched rows as the number of rows in the reduced frame.
+	 */
+	if (num_matched_rows <= 0)
+	{
+		/* no match */
+		register_reduced_frame_map(winstate, original_pos, RF_UNMATCHED);
+	}
+	else
+	{
+		int64		i;
+
+		register_reduced_frame_map(winstate, original_pos, RF_FRAME_HEAD);
+
+		for (i = original_pos + 1; i < original_pos + num_matched_rows; i++)
+		{
+			register_reduced_frame_map(winstate, i, RF_SKIPPED);
+		}
+	}
+
+	return;
+}
+
+/*
+ * search set of encode_str.
+ * set_size: size of set_str array.
+ */
+static
+int search_str_set(char *pattern, StringInfo *str_set, int set_size)
+{
+	char		*encoded_str = palloc0(set_size+1);
+	int			resultlen = 0;
+
+	search_str_set_recurse(pattern, str_set, set_size, 0, encoded_str, &resultlen);
+	elog(DEBUG1, "search_str_set returns %d", resultlen);
+	return resultlen;
+}
+
+/*
+ * Workhorse of search_str_set.
+ */
+static
+void search_str_set_recurse(char *pattern, StringInfo *str_set,
+							int set_size, int set_index, char *encoded_str, int *resultlen)
+{
+	char	*p;
+
+	if (set_index >= set_size)
+	{
+		Datum	d;
+		text	*res;
+		char	*substr;
+
+		/*
+		 * We first perform pattern matching using regexp_instr, then call
+		 * textregexsubstr to get matched substring to know how log the
+		 * matched string is. That is the number of rows in the reduced window
+		 * frame.  The reason why we can't call textregexsubstr is, it error
+		 * out if pattern is not match.
+		 */
+		if (DatumGetInt32(DirectFunctionCall2Coll(regexp_instr, DEFAULT_COLLATION_OID,
+												  PointerGetDatum(cstring_to_text(encoded_str)),
+												  PointerGetDatum(cstring_to_text(pattern)))) > 0)
+		{
+			d = DirectFunctionCall2Coll(textregexsubstr,
+										DEFAULT_COLLATION_OID,
+										PointerGetDatum(cstring_to_text(encoded_str)),
+										PointerGetDatum(cstring_to_text(pattern)));
+			if (d != 0)
+			{
+				int		len;
+
+				res = DatumGetTextPP(d);
+				substr = text_to_cstring(res);
+				len = strlen(substr);
+				if (len > *resultlen)
+					/* remember the longest match */
+					*resultlen = len;
+			}
+		}
+		return;
+	}
+
+	p = str_set[set_index]->data;
+	while (*p)
+	{
+		encoded_str[set_index] = *p;
+		p++;
+		search_str_set_recurse(pattern, str_set, set_size, set_index + 1, encoded_str, resultlen);
+	}
+}
+
+
+/*
+ * Evaluate expression associated with PATTERN variable vname.
+ * relpos is relative row position in a frame (starting from 0).
+ * "quantifier" is the quatifier part of the PATTERN regular expression.
+ * Currently only '+' is allowed.
+ * result is out paramater representing the expression evaluation result
+ * is true of false.
+ * Return values are:
+ * >=0: the last match absolute row position
+ * other wise out of frame.
+ */
+static
+int64 evaluate_pattern(WindowObject winobj, int64 current_pos,
+						char *vname, StringInfo encoded_str, bool *result)
+{
+	WindowAggState	*winstate = winobj->winstate;
+	ExprContext		*econtext = winstate->ss.ps.ps_ExprContext;
+	ListCell		*lc1, *lc2, *lc3;
+	ExprState		*pat;
+	Datum			eval_result;
+	bool			out_of_frame = false;
+	bool			isnull;
+
+	forthree (lc1, winstate->defineVariableList, lc2, winstate->defineClauseList, lc3, winstate->defineInitial)
+	{
+		char	initial;
+		char	*name = strVal(lfirst(lc1));
+
+		if (strcmp(vname, name))
+			continue;
+
+		initial = *(strVal(lfirst(lc3)));
+
+		/* set expression to evaluate */
+		pat = lfirst(lc2);
+
+		/* get current, previous and next tuples */
+		if (!get_slots(winobj, current_pos))
+		{
+			out_of_frame = true;
+		}
+		else
+		{
+			/* evaluate the expression */
+			eval_result = ExecEvalExpr(pat, econtext, &isnull);
+			if (isnull)
+			{
+				/* expression is NULL */
+				elog(DEBUG1, "expression for %s is NULL at row: " INT64_FORMAT, vname, current_pos);
+				*result = false;
+			}
+			else
+			{
+				if (!DatumGetBool(eval_result))
+				{
+					/* expression is false */
+					elog(DEBUG1, "expression for %s is false at row: " INT64_FORMAT, vname, current_pos);
+					*result = false;
+				}
+				else
+				{
+					/* expression is true */
+					elog(DEBUG1, "expression for %s is true at row: " INT64_FORMAT, vname, current_pos);
+					appendStringInfoChar(encoded_str, initial);
+					*result = true;
+				}
+			}
+			break;
+		}
+
+		if (out_of_frame)
+		{
+			*result = false;
+			return -1;
+		}
+	}
+	return current_pos;
+}
+
+/*
+ * Get current, previous and next tuples.
+ * Returns false if current row is out of partition/full frame.
+ */
+static
+bool get_slots(WindowObject winobj, int64 current_pos)
+{
+	WindowAggState *winstate = winobj->winstate;
+	TupleTableSlot *slot;
+	int		ret;
+	ExprContext *econtext;
+
+	econtext = winstate->ss.ps.ps_ExprContext;
+
+	/* set up current row tuple slot */
+	slot = winstate->temp_slot_1;
+	if (!window_gettupleslot(winobj, current_pos, slot))
+	{
+		elog(DEBUG1, "current row is out of partition at:" INT64_FORMAT, current_pos);
+		return false;
+
+		ret = row_is_in_frame(winstate, current_pos, slot);
+		if (ret <= 0)
+		{
+			elog(DEBUG1, "current row is out of frame at: " INT64_FORMAT, current_pos);
+			return false;
+		}
+	}
+	econtext->ecxt_outertuple = slot;
+
+	/* for PREV */
+	if (current_pos > 0)
+	{
+		slot = winstate->prev_slot;
+		if (!window_gettupleslot(winobj, current_pos - 1, slot))
+		{
+			elog(DEBUG1, "previous row is out of partition at: " INT64_FORMAT, current_pos - 1);
+			econtext->ecxt_scantuple = winstate->null_slot;
+		}
+		else
+		{
+			ret = row_is_in_frame(winstate, current_pos - 1, slot);
+			if (ret <= 0)
+			{
+				elog(DEBUG1, "previous row is out of frame at: " INT64_FORMAT, current_pos - 1);
+				econtext->ecxt_scantuple = winstate->null_slot;
+			}
+			else
+			{
+				econtext->ecxt_scantuple = slot;
+			}
+		}
+	}
+	else
+		econtext->ecxt_scantuple = winstate->null_slot;
+
+	/* for NEXT */
+	slot = winstate->next_slot;
+	if (!window_gettupleslot(winobj, current_pos + 1, slot))
+	{
+		elog(DEBUG1, "next row is out of partiton at: " INT64_FORMAT, current_pos + 1);
+		econtext->ecxt_innertuple = winstate->null_slot;
+	}
+	else
+	{
+		ret = row_is_in_frame(winstate, current_pos + 1, slot);
+		if (ret <= 0)
+		{
+			elog(DEBUG1, "next row is out of frame at: " INT64_FORMAT, current_pos + 1);
+			econtext->ecxt_innertuple = winstate->null_slot;
+		}
+		else
+			econtext->ecxt_innertuple = slot;
+	}
+	return true;
+}
+
+/*
+ * Return pattern variable initial character
+ * matching with pattern variable name vname.
+ * If not found, return 0.
+ */
+static
+char	pattern_initial(WindowAggState *winstate, char *vname)
+{
+	char		initial;
+	char		*name;
+	ListCell	*lc1, *lc2;
+
+	forboth (lc1, winstate->defineVariableList, lc2, winstate->defineInitial)
+	{
+		name = strVal(lfirst(lc1));				/* DEFINE variable name */
+		initial = *(strVal(lfirst(lc2)));		/* DEFINE variable initial */
+
+
+		if (!strcmp(name, vname))
+				return initial;					/* found */
+	}
+	return 0;
+}
diff --git a/src/backend/utils/adt/windowfuncs.c b/src/backend/utils/adt/windowfuncs.c
index b87a624fb2..9ebcc7b5d2 100644
--- a/src/backend/utils/adt/windowfuncs.c
+++ b/src/backend/utils/adt/windowfuncs.c
@@ -13,6 +13,9 @@
  */
 #include "postgres.h"
 
+#include "catalog/pg_collation_d.h"
+#include "executor/executor.h"
+#include "nodes/execnodes.h"
 #include "nodes/supportnodes.h"
 #include "utils/builtins.h"
 #include "windowapi.h"
@@ -36,11 +39,19 @@ typedef struct
 	int64		remainder;		/* (total rows) % (bucket num) */
 } ntile_context;
 
+/*
+ * rpr process information.
+ * Used for AFTER MATCH SKIP PAST LAST ROW
+ */
+typedef struct SkipContext
+{
+	int64		pos;	/* last row absolute position */
+} SkipContext;
+
 static bool rank_up(WindowObject winobj);
 static Datum leadlag_common(FunctionCallInfo fcinfo,
 							bool forward, bool withoffset, bool withdefault);
 
-
 /*
  * utility routine for *_rank functions.
  */
@@ -673,7 +684,7 @@ window_last_value(PG_FUNCTION_ARGS)
 	bool		isnull;
 
 	result = WinGetFuncArgInFrame(winobj, 0,
-								  0, WINDOW_SEEK_TAIL, true,
+								  0, WINDOW_SEEK_TAIL, false,
 								  &isnull, NULL);
 	if (isnull)
 		PG_RETURN_NULL();
@@ -713,3 +724,25 @@ window_nth_value(PG_FUNCTION_ARGS)
 
 	PG_RETURN_DATUM(result);
 }
+
+/*
+ * prev
+ * Dummy function to invoke RPR's navigation operator "PREV".
+ * This is *not* a window function.
+ */
+Datum
+window_prev(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_DATUM(PG_GETARG_DATUM(0));
+}
+
+/*
+ * next
+ * Dummy function to invoke RPR's navigation operation "NEXT".
+ * This is *not* a window function.
+ */
+Datum
+window_next(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_DATUM(PG_GETARG_DATUM(0));
+}
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 9805bc6118..d20f803cf5 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -10416,6 +10416,12 @@
 { oid => '3114', descr => 'fetch the Nth row value',
   proname => 'nth_value', prokind => 'w', prorettype => 'anyelement',
   proargtypes => 'anyelement int4', prosrc => 'window_nth_value' },
+{ oid => '6122', descr => 'previous value',
+  proname => 'prev', provolatile => 's', prorettype => 'anyelement',
+  proargtypes => 'anyelement', prosrc => 'window_prev' },
+{ oid => '6123', descr => 'next value',
+  proname => 'next', provolatile => 's', prorettype => 'anyelement',
+  proargtypes => 'anyelement', prosrc => 'window_next' },
 
 # functions for range types
 { oid => '3832', descr => 'I/O',
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index cb714f4a19..63feb68f60 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -2471,6 +2471,11 @@ typedef enum WindowAggStatus
 									 * tuples during spool */
 } WindowAggStatus;
 
+#define	RF_NOT_DETERMINED	0
+#define	RF_FRAME_HEAD		1
+#define	RF_SKIPPED			2
+#define	RF_UNMATCHED		3
+
 typedef struct WindowAggState
 {
 	ScanState	ss;				/* its first field is NodeTag */
@@ -2519,6 +2524,15 @@ typedef struct WindowAggState
 	int64		groupheadpos;	/* current row's peer group head position */
 	int64		grouptailpos;	/* " " " " tail position (group end+1) */
 
+	/* these fields are used in Row pattern recognition: */
+	RPSkipTo	rpSkipTo;		/* Row Pattern Skip To type */
+	List	   *patternVariableList;	/* list of row pattern variables names (list of String) */
+	List	   *patternRegexpList;	/* list of row pattern regular expressions ('+' or ''. list of String) */
+	List	   *defineVariableList;	/* list of row pattern definition variables (list of String) */
+	List	   *defineClauseList;	/* expression for row pattern definition
+									 * search conditions ExprState list */
+	List	   *defineInitial;		/* list of row pattern definition variable initials (list of String) */
+
 	MemoryContext partcontext;	/* context for partition-lifespan data */
 	MemoryContext aggcontext;	/* shared context for aggregate working data */
 	MemoryContext curaggcontext;	/* current aggregate's working data */
@@ -2555,6 +2569,18 @@ typedef struct WindowAggState
 	TupleTableSlot *agg_row_slot;
 	TupleTableSlot *temp_slot_1;
 	TupleTableSlot *temp_slot_2;
+
+	/* temporary slots for RPR */
+	TupleTableSlot *prev_slot;	/* PREV row navigation operator */
+	TupleTableSlot *next_slot;	/* NEXT row navigation operator */
+	TupleTableSlot *null_slot;	/* all NULL slot */
+
+	/*
+	 * Each byte corresponds to a row positioned at absolute its pos in
+	 * partition.  See above definition for RF_*
+	 */
+	char		*reduced_frame_map;
+	int64		alloc_sz;	/* size of the map */
 } WindowAggState;
 
 /* ----------------
-- 
2.25.1


----Next_Part(Tue_Sep_12_15_18_43_2023_359)--
Content-Type: Text/X-Patch; charset=us-ascii
Content-Transfer-Encoding: 7bit
Content-Disposition: inline;
 filename="v6-0005-Row-pattern-recognition-patch-docs.patch"



^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread

* [PATCH 1/2] Demonstrate possible race conditions in logical decoding.
@ 2026-01-19 11:54  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 486+ messages in thread

From: Antonin Houska @ 2026-01-19 11:54 UTC (permalink / raw)

The problem is that the snapshot builder can create a snapshot before CLOG has
been updated. That breaks visibility check that use such snapshot. For more
details, see startup_race.spec.

Success of the test means that the problem is present. Thus it would need to
be modified if it should be merged into the tree.

Another problem that currently prevents this test from being merged is that it
hard-wires the logical decoding setup into the SET TRANSACTION command. I
tried to modify the isolation tester so it can use the logical replication
protocol (in which case the test could use the "CREATE_REPLICATION_SNAPSHOT
... (SNAPSHOT 'use')" command), but the tester does things that are not
compatible with that protocol (e.g. it sets the application_name parameter).
---
 .../test_decoding/expected/startup_race.out   |  85 ++++++++++++
 contrib/test_decoding/specs/startup_race.spec | 126 ++++++++++++++++++
 src/backend/access/transam/xact.c             |   6 +
 src/backend/replication/logical/snapbuild.c   |   3 +
 src/backend/utils/time/snapmgr.c              |  66 +++++++++
 src/include/utils/snapmgr.h                   |   1 +
 6 files changed, 287 insertions(+)
 create mode 100644 contrib/test_decoding/expected/startup_race.out
 create mode 100644 contrib/test_decoding/specs/startup_race.spec

diff --git a/contrib/test_decoding/expected/startup_race.out b/contrib/test_decoding/expected/startup_race.out
new file mode 100644
index 00000000000..597fa617831
--- /dev/null
+++ b/contrib/test_decoding/expected/startup_race.out
@@ -0,0 +1,85 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s1_assign_xid s2_set_snapshot s3_assign_xid s1_rollback s3_rollback s4_do_changes s5_wake_up_full_snapshot s2_scan s2_rollback s5_wake_up_before_clog s6_check
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_assign_xid: 
+	BEGIN;
+	CREATE TABLE b(i int);
+
+step s2_set_snapshot: 
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+ <waiting ...>
+step s3_assign_xid: 
+	BEGIN;
+	CREATE TABLE c(i int);
+
+step s1_rollback: 
+	ROLLBACK;
+
+step s3_rollback: 
+	ROLLBACK;
+
+step s4_do_changes: 
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+ <waiting ...>
+step s5_wake_up_full_snapshot: 
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s2_set_snapshot: <... completed>
+step s2_scan: 
+	TABLE a;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+step s2_rollback: 
+	ROLLBACK;
+
+step s5_wake_up_before_clog: 
+	SELECT injection_points_wakeup('before-clog-update');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s4_do_changes: <... completed>
+step s6_check: 
+	SELECT * FROM a ORDER BY i;
+
+i|j
+-+-
+1|1
+2|2
+(2 rows)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/contrib/test_decoding/specs/startup_race.spec b/contrib/test_decoding/specs/startup_race.spec
new file mode 100644
index 00000000000..8f67e07fa7a
--- /dev/null
+++ b/contrib/test_decoding/specs/startup_race.spec
@@ -0,0 +1,126 @@
+setup
+{
+	CREATE TABLE a(i int primary key, j int) WITH (autovacuum_enabled = off);
+	INSERT INTO a(i, j) VALUES (1, 1), (2, 2);
+	CREATE EXTENSION injection_points;
+}
+
+session s1
+step s1_assign_xid
+{
+	BEGIN;
+	CREATE TABLE b(i int);
+}
+step s1_rollback
+{
+	ROLLBACK;
+}
+
+session s2
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('snapbuild-full-snapshot', 'wait');
+}
+# Use special, hard-wired snapshot name to set the initial snapshot from
+# logical replication slot.
+step s2_set_snapshot
+{
+	BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;
+	SET TRANSACTION SNAPSHOT 'from_slot';
+}
+# Perform the scan.
+step s2_scan
+{
+	TABLE a;
+}
+step s2_rollback
+{
+	ROLLBACK;
+}
+teardown
+{
+	SELECT injection_points_detach('snapbuild-full-snapshot');
+}
+
+session s3
+step s3_assign_xid
+{
+	BEGIN;
+	CREATE TABLE c(i int);
+}
+step s3_rollback
+{
+	ROLLBACK;
+}
+
+session s4
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('before-clog-update', 'wait');
+}
+step s4_do_changes
+{
+	INSERT INTO a(i, j) VALUES (3, 3);
+	UPDATE a SET j = j + 1 WHERE i = 1;
+	DELETE FROM a WHERE i = 2;
+}
+teardown
+{
+	SELECT injection_points_detach('before-clog-update');
+}
+
+session s5
+step s5_wake_up_full_snapshot
+{
+	SELECT injection_points_wakeup('snapbuild-full-snapshot');
+}
+step s5_wake_up_before_clog
+{
+	SELECT injection_points_wakeup('before-clog-update');
+}
+
+session s6
+step s6_check
+{
+	SELECT * FROM a ORDER BY i;
+}
+
+permutation
+# Let the snapshot builder go through all the states. The problematic case
+# happens in the FULL_SNAPSHOT.
+s1_assign_xid
+# This should leave the builder in BUILDING_SNAPSHOT, waiting for the active
+# transaction to end.
+s2_set_snapshot
+# Make sure that s1_rollback does not allow going to CONSISTENT directly.
+s3_assign_xid
+# Let the builder proceed to FULL_SNAPSHOT. It should stop at the injection
+# point 'snapbuild-full-snapshot'.
+s1_rollback
+# The transaction of s3 is not needed anymore, CONSISTENT should be the next
+# stage.
+s3_rollback
+# Perform data changes before the snapshot builder triggers creation of the
+# RUNNING_XACTS record. This will stop before setting transaction status in
+# CLOG.
+s4_do_changes
+# Unblock the injection point so that the snapshot can finally be created.
+s5_wake_up_full_snapshot
+# Use the snapshot for a scan. The snapshot will consider s4 not running
+# anymore, however CLOG is not aware of the commit yet. Thus
+# HeapTupleSatisfiesMVCC will consider the transaction aborted. In particular,
+# for UPDATE, if both xmax of the old version and xmin of the new version are
+# considered aborted, so the effects of the UPDATE are lost
+# altogether. Similarly, INSERT and DELETE have no effect because the xmin
+# transaction of the inserted tuple and xmax of the deleted tuple are
+# considered aborted
+s2_scan
+s2_rollback
+# CLOG can be updated now.
+s5_wake_up_before_clog
+# Scan the table again using a new transaction, with a normal transaction
+# snapshot. The results are still wrong due to hint bits set incorrectly.
+s6_check
+
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index c857e23552f..2fea45b2fed 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "utils/builtins.h"
 #include "utils/combocid.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/relmapper.h"
@@ -1348,6 +1349,9 @@ RecordTransactionCommit(void)
 													 &RelcacheInitFileInval);
 	wrote_xlog = (XactLastRecEnd != 0);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("before-clog-update");
+
 	/*
 	 * If we haven't been assigned an XID yet, we neither can, nor do we want
 	 * to write a COMMIT record.
@@ -1514,6 +1518,8 @@ RecordTransactionCommit(void)
 	{
 		XLogFlush(XactLastRecEnd);
 
+		INJECTION_POINT_CACHED("before-clog-update", NULL);
+
 		/*
 		 * Now we may update the CLOG, if we wrote a COMMIT record above
 		 */
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index 7f79621b57e..9b09dc8eac1 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -141,6 +141,7 @@
 #include "storage/procarray.h"
 #include "storage/standby.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 #include "utils/snapshot.h"
@@ -1387,6 +1388,8 @@ SnapBuildFindSnapshot(SnapBuild *builder, XLogRecPtr lsn, xl_running_xacts *runn
 				errdetail("Waiting for transactions (approximately %d) older than %u to end.",
 						  running->xcnt, running->nextXid));
 
+		INJECTION_POINT("snapbuild-full-snapshot", NULL);
+
 		SnapBuildWaitSnapshot(running, running->nextXid);
 	}
 
diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c
index 2e6197f5f35..f327b779004 100644
--- a/src/backend/utils/time/snapmgr.c
+++ b/src/backend/utils/time/snapmgr.c
@@ -110,10 +110,13 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/xact.h"
+#include "access/xlogutils.h"
 #include "datatype/timestamp.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
 #include "port/pg_lfind.h"
+#include "replication/logical.h"
+#include "replication/snapbuild.h"
 #include "storage/fd.h"
 #include "storage/predicate.h"
 #include "storage/proc.h"
@@ -1421,6 +1424,17 @@ ImportSnapshot(const char *idstr)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ")));
 
+	if (strcmp(idstr, "from_slot") == 0)
+	{
+		Snapshot	snap;
+
+		snap = create_test_snapshot();
+		/* XXX sourcevxid shouldn't be needed in this special case */
+		SetTransactionSnapshot(snap, NULL, MyProcPid, MyProc);
+
+		return;
+	}
+
 	/*
 	 * Verify the identifier: only 0-9, A-F and hyphens are allowed.  We do
 	 * this mainly to prevent reading arbitrary files.
@@ -1969,3 +1983,55 @@ ResOwnerReleaseSnapshot(Datum res)
 {
 	UnregisterSnapshotNoOwner((Snapshot) DatumGetPointer(res));
 }
+
+/*
+ * CreateReplicationSlot() with the CRS_USE_SNAPSHOT option would be useful
+ * for testing, but regression tests cannot speak both replication and query
+ * protocol at the same time. This function can be used instead to create a
+ * snapshot for special tests of logical replication.
+ */
+Snapshot
+create_test_snapshot(void)
+{
+	const	char	*slotname = "test_slot";
+	const	char *plugin;
+	LogicalDecodingContext *ctx;
+	Snapshot	snap;
+
+	/*
+	 * XXX Hard-wired values are fine for the special test that needs this
+	 * function.
+	 */
+	plugin = "test_decoding";
+
+	Assert(!MyReplicationSlot);
+
+	CheckLogicalDecodingRequirements();
+
+	ReplicationSlotCreate(slotname, true, RS_TEMPORARY,
+						  false, false, false);
+
+	/*
+	 * Ensure the logical decoding is enabled before initializing the
+	 * logical decoding context.
+	 */
+	EnsureLogicalDecodingEnabled();
+	Assert(IsLogicalDecodingEnabled());
+
+	ctx = CreateInitDecodingContext(plugin, NIL, true,
+									InvalidXLogRecPtr,
+									XL_ROUTINE(.page_read = read_local_xlog_page,
+											   .segment_open = wal_segment_open,
+											   .segment_close = wal_segment_close),
+									NULL, NULL, NULL);
+
+	/* build initial snapshot, might take a while */
+	DecodingContextFindStartpoint(ctx);
+
+	/* Do what the function is called for. */
+	snap = SnapBuildInitialSnapshot(ctx->snapshot_builder);
+	snap = CopySnapshot(snap);
+	FreeDecodingContext(ctx);
+
+	return snap;
+}
diff --git a/src/include/utils/snapmgr.h b/src/include/utils/snapmgr.h
index b8c01a291a1..9f0d60ebc1b 100644
--- a/src/include/utils/snapmgr.h
+++ b/src/include/utils/snapmgr.h
@@ -123,4 +123,5 @@ extern Snapshot RestoreSnapshot(char *start_address);
 struct PGPROC;
 extern void RestoreTransactionSnapshot(Snapshot snapshot, struct PGPROC *source_pgproc);
 
+extern Snapshot create_test_snapshot(void);
 #endif							/* SNAPMGR_H */
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 486+ messages in thread


end of thread, other threads:[~2026-01-19 11:54 UTC | newest]

Thread overview: 486+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2019-05-10 02:22 [PATCH v5 12/12] s/recommendable/recommended Justin Pryzby <[email protected]>
2019-05-10 02:22 [PATCH v3 11/12] s/recommendable/recommended Justin Pryzby <[email protected]>
2023-09-12 05:22 [PATCH v6 4/7] Row pattern recognition patch (executor). Tatsuo Ishii <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>
2026-01-19 11:54 [PATCH 1/2] Demonstrate possible race conditions in logical decoding. Antonin Houska <[email protected]>

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox