From 8872b44f35dc4f7b6ecb0f77e034500fec5f0fa3 Mon Sep 17 00:00:00 2001
From: spoondla <s_poondla@apple.com>
Date: Fri, 23 Jan 2026 17:28:54 -0800
Subject: [PATCH v10] Fix (bug #19382) server crash when ALTER TYPE is used
 mid-transaction in PL/pgSQL

When ALTER TYPE changes a composite type's column types within a
transaction, PL/pgSQL record variables that were populated before the
ALTER still hold data in the old format.  Reading such a record later
(RETURN, RETURN NEXT, RAISE, etc.) crashes because the output functions
interpret the stored bytes using the new type definition.  The crash
manifested as a segmentation fault in record_out() when it attempted to
interpret integer data as a text pointer.

The fix snapshots tupDesc_identifier values for all composite types
reachable from a record variable's type at assignment time, and compares
them against the current type cache values before the record is read.  If
any identifier has changed, an error is raised instead of risking a crash.

Details:
1. The type tree is walked from an effective root type.  For a variable
declared as a named composite the declared type is used; for a variable
declared as generic RECORD the actual type adopted from the assigned
value (er_typeid) is used, so "r record := ROW(...)::foo" is covered.
Truly anonymous rowtypes are not versioned and are left unchecked.

2. The walk descends through domains and through container types (arrays,
ranges, and multiranges) to reach composite element types, so a field
such as "t_comp[]" is tracked and an ALTER TYPE on t_comp is detected.

3. The snapshot is refreshed on every whole-record assignment, including
the in-place expanded_record_set_tuple paths, so that reassigning a
record with fresh data after an ALTER TYPE is correctly allowed rather
than rejected.

The outermost type is additionally checked via the ExpandedRecord's
er_tupdesc_id, which covers field-by-field assignment and SELECT INTO
paths that do not rebuild the record wholesale.
---
 .../plpgsql/src/expected/plpgsql_record.out   | 262 ++++++++++++++
 src/pl/plpgsql/src/pl_exec.c                  | 324 +++++++++++++++++-
 src/pl/plpgsql/src/plpgsql.h                  |   9 +
 src/pl/plpgsql/src/sql/plpgsql_record.sql     | 236 +++++++++++++
 4 files changed, 830 insertions(+), 1 deletion(-)

diff --git a/src/pl/plpgsql/src/expected/plpgsql_record.out b/src/pl/plpgsql/src/expected/plpgsql_record.out
index 511f9e03c85..38aa162bdea 100644
--- a/src/pl/plpgsql/src/expected/plpgsql_record.out
+++ b/src/pl/plpgsql/src/expected/plpgsql_record.out
@@ -885,3 +885,265 @@ table two_int8s_tab;
  (42,42)
 (1 row)
 
+-- Tests for bug #19382: server crash when ALTER TYPE is used mid-transaction
+-- in PL/pgSQL. Record variables populated before ALTER TYPE must not be
+-- returned, as the stored data no longer matches the current type definition.
+-- Case 1: Direct composite type change (INT -> TEXT)
+create type bug19382_foo as (a int, b int);
+create function bug19382_test_direct() returns record as $$
+declare r bug19382_foo := row(123, power(2, 30));
+begin
+    alter type bug19382_foo alter attribute b type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_direct();
+ERROR:  cannot return record variable "r" after composite type "bug19382_foo" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_direct() line 5 at RETURN
+drop function bug19382_test_direct();
+drop type bug19382_foo cascade;
+-- Case 2: Nested composite type change
+create type bug19382_inner as (x int, y int);
+create type bug19382_outer as (a int, b bug19382_inner);
+create function bug19382_test_nested() returns record as $$
+declare r bug19382_outer;
+begin
+    r := row(1, row(10, power(2, 30)::int4)::bug19382_inner)::bug19382_outer;
+    alter type bug19382_inner alter attribute y type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_nested();
+ERROR:  cannot return record variable "r" after composite type "bug19382_inner" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_nested() line 6 at RETURN
+drop function bug19382_test_nested();
+drop type bug19382_outer cascade;
+drop type bug19382_inner cascade;
+-- Case 3: OUT parameter
+create type bug19382_foo1 as (a int, b int);
+create function bug19382_test_out(out r1 bug19382_foo1) as $$
+begin
+    r1 := row(1, 2);
+    alter type bug19382_foo1 alter attribute b type text;
+    return;
+end;
+$$ language plpgsql;
+select bug19382_test_out();
+ERROR:  cannot return record variable "r1" after composite type "bug19382_foo1" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_out() line 5 at RETURN
+drop function bug19382_test_out();
+drop type bug19382_foo1 cascade;
+-- Case 4: No ALTER TYPE (baseline — must not error)
+create type bug19382_foo2 as (a int, b int);
+create function bug19382_test_baseline() returns bug19382_foo2 as $$
+declare r bug19382_foo2 := row(1, 2);
+begin
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_baseline();
+ bug19382_test_baseline 
+------------------------
+ (1,2)
+(1 row)
+
+drop function bug19382_test_baseline();
+drop type bug19382_foo2;
+-- Case 5: Field-by-field assignment (dot notation)
+create type bug19382_foo3 as (a int, b int);
+create function bug19382_test_field_assign() returns record as $$
+declare r bug19382_foo3;
+begin
+    r.a := 123;
+    r.b := power(2, 30)::int4;
+    alter type bug19382_foo3 alter attribute b type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_field_assign();
+ERROR:  cannot return record variable "r" after composite type "bug19382_foo3" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_field_assign() line 7 at RETURN
+drop function bug19382_test_field_assign();
+drop type bug19382_foo3 cascade;
+-- Case 6: SELECT INTO individual fields
+create type bug19382_foo4 as (a int, b int);
+create table bug19382_tbl (a int, b int);
+insert into bug19382_tbl values (123, power(2, 30)::int4);
+create function bug19382_test_select_into_field() returns record as $$
+declare r bug19382_foo4;
+begin
+    select a, b into r.a, r.b from bug19382_tbl;
+    alter type bug19382_foo4 alter attribute b type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_select_into_field();
+ERROR:  cannot return record variable "r" after composite type "bug19382_foo4" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_select_into_field() line 6 at RETURN
+drop function bug19382_test_select_into_field();
+drop table bug19382_tbl;
+drop type bug19382_foo4 cascade;
+-- Case 7: RAISE NOTICE with record variable (exec_eval_datum path)
+create type bug19382_foo5 as (a int, b int);
+create function bug19382_test_eval_datum() returns void as $$
+declare r bug19382_foo5;
+begin
+    r.b := power(2, 30)::int4;
+    alter type bug19382_foo5 alter attribute b type text;
+    raise notice 'r = %', r;
+end;
+$$ language plpgsql;
+select bug19382_test_eval_datum();
+ERROR:  cannot return record variable "r" after composite type "bug19382_foo5" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_eval_datum() line 6 at RAISE
+drop function bug19382_test_eval_datum();
+drop type bug19382_foo5 cascade;
+-- Case 8: Generic RECORD variable assigned via ROW::foo cast
+-- The declared type is RECORDOID; effective type is discovered from the
+-- assigned value's er_typeid.  Must error, not crash.
+create type bug19382_foo6 as (a int, b int);
+create function bug19382_test_generic_record() returns record as $$
+declare r record;
+begin
+    r := row(123, power(2, 30)::int4)::bug19382_foo6;
+    alter type bug19382_foo6 alter attribute b type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_generic_record();
+ERROR:  cannot return record variable "r" after composite type "bug19382_foo6" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_generic_record() line 6 at RETURN
+drop function bug19382_test_generic_record();
+drop type bug19382_foo6 cascade;
+-- Case 9: Generic RECORD with nested composite alter
+create type bug19382_inner2 as (x int, y int);
+create type bug19382_outer2 as (a int, b bug19382_inner2);
+create function bug19382_test_generic_nested() returns record as $$
+declare r record;
+begin
+    r := row(1, row(10, power(2, 30)::int4)::bug19382_inner2)::bug19382_outer2;
+    alter type bug19382_inner2 alter attribute y type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_generic_nested();
+ERROR:  cannot return record variable "r" after composite type "bug19382_inner2" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_generic_nested() line 6 at RETURN
+drop function bug19382_test_generic_nested();
+drop type bug19382_outer2 cascade;
+drop type bug19382_inner2 cascade;
+-- Case 10: Whole-record reassignment after inner-type alter.
+-- The second assignment builds fresh data matching the post-ALTER type;
+-- the snapshot must refresh so the reassignment succeeds.
+create type bug19382_inner3 as (x int, y int);
+create type bug19382_outer3 as (a int, b bug19382_inner3);
+create function bug19382_test_reassign() returns bug19382_outer3 as $$
+declare r bug19382_outer3;
+begin
+    r := row(1, row(10, 20)::bug19382_inner3)::bug19382_outer3;
+    alter type bug19382_inner3 alter attribute y type text;
+    r := row(1, row(10, 'hello')::bug19382_inner3)::bug19382_outer3;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_reassign();
+ bug19382_test_reassign 
+------------------------
+ (1,"(10,hello)")
+(1 row)
+
+drop function bug19382_test_reassign();
+drop type bug19382_outer3 cascade;
+drop type bug19382_inner3 cascade;
+-- Case 11: Anonymous rowtype baseline (RECORDOID with no ::foo cast).
+-- Non-versionable rowtypes must not trigger a false positive.
+create function bug19382_test_anon_baseline() returns record as $$
+declare r record;
+begin
+    select 1 as a, 2 as b into r;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_anon_baseline();
+ bug19382_test_anon_baseline 
+-----------------------------
+ (1,2)
+(1 row)
+
+drop function bug19382_test_anon_baseline();
+-- Case 12: Variable-to-variable copy after inner-type alter.
+-- Reading r1 as an rvalue must detect the stale nested type.
+create type bug19382_inner4 as (x int, y int);
+create type bug19382_outer4 as (a int, b bug19382_inner4);
+create function bug19382_test_var_copy() returns bug19382_outer4 as $$
+declare r1 bug19382_outer4; r2 bug19382_outer4;
+begin
+    r1 := row(1, row(10, power(2, 30)::int4)::bug19382_inner4)::bug19382_outer4;
+    alter type bug19382_inner4 alter attribute y type text;
+    r2 := r1;
+    return r2;
+end;
+$$ language plpgsql;
+select bug19382_test_var_copy();
+ERROR:  cannot return record variable "r1" after composite type "bug19382_inner4" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_var_copy() line 6 at assignment
+drop function bug19382_test_var_copy();
+drop type bug19382_outer4 cascade;
+drop type bug19382_inner4 cascade;
+-- Case 13: RAISE NOTICE with generic RECORD (RECORDOID + reader path).
+create type bug19382_foo7 as (a int, b int);
+create function bug19382_test_generic_raise() returns void as $$
+declare r record;
+begin
+    r := row(1, power(2, 30)::int4)::bug19382_foo7;
+    alter type bug19382_foo7 alter attribute b type text;
+    raise notice 'r = %', r;
+end;
+$$ language plpgsql;
+select bug19382_test_generic_raise();
+ERROR:  cannot return record variable "r" after composite type "bug19382_foo7" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_generic_raise() line 6 at RAISE
+drop function bug19382_test_generic_raise();
+drop type bug19382_foo7 cascade;
+-- Case 14: composite type reachable through an array attribute.
+-- The element composite type must be tracked even though the attribute's
+-- own type is an array; otherwise ALTER TYPE on the element goes undetected
+-- and record_out() crashes reinterpreting the stored bytes.
+create type bug19382_elem as (f1 int);
+create type bug19382_arrparent as (arr bug19382_elem[]);
+create function bug19382_test_array_elem() returns bug19382_arrparent as $$
+declare r bug19382_arrparent;
+begin
+    r := row(array[row(power(2, 30)::int4)::bug19382_elem]::bug19382_elem[])::bug19382_arrparent;
+    alter type bug19382_elem alter attribute f1 type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_array_elem();
+ERROR:  cannot return record variable "r" after composite type "bug19382_elem" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_array_elem() line 6 at RETURN
+drop function bug19382_test_array_elem();
+drop type bug19382_arrparent cascade;
+drop type bug19382_elem cascade;
+-- Case 15: array element composite, whole-record reassignment after ALTER
+-- with fresh matching data must succeed (no false positive on arrays).
+create type bug19382_elem2 as (f1 int);
+create type bug19382_arrparent2 as (arr bug19382_elem2[]);
+create function bug19382_test_array_reassign() returns bug19382_arrparent2 as $$
+declare r bug19382_arrparent2;
+begin
+    r := row(array[row(1)::bug19382_elem2]::bug19382_elem2[])::bug19382_arrparent2;
+    alter type bug19382_elem2 add attribute f2 text;
+    r := row(array[row(1, 'hi')::bug19382_elem2]::bug19382_elem2[])::bug19382_arrparent2;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_array_reassign();
+ bug19382_test_array_reassign 
+------------------------------
+ ("{""(1,hi)""}")
+(1 row)
+
+drop function bug19382_test_array_reassign();
+drop type bug19382_arrparent2 cascade;
+drop type bug19382_elem2 cascade;
diff --git a/src/pl/plpgsql/src/pl_exec.c b/src/pl/plpgsql/src/pl_exec.c
index 65b0fd0790f..9c0be5ac756 100644
--- a/src/pl/plpgsql/src/pl_exec.c
+++ b/src/pl/plpgsql/src/pl_exec.c
@@ -470,6 +470,12 @@ static char *format_preparedparamsdata(PLpgSQL_execstate *estate,
 static PLpgSQL_variable *make_callstmt_target(PLpgSQL_execstate *estate,
 											  PLpgSQL_expr *expr);
 
+static void check_record_type_not_altered(PLpgSQL_rec *rec);
+static void collect_composite_type_versions(Oid typid,
+											Oid **oids, uint64 **versions,
+											int *n, int *alloc);
+static void snapshot_record_composite_types(PLpgSQL_execstate *estate,
+											PLpgSQL_rec *rec);
 
 /* ----------
  * plpgsql_exec_function	Called by the call handler for
@@ -3287,8 +3293,30 @@ exec_stmt_return(PLpgSQL_execstate *estate, PLpgSQL_stmt_return *stmt)
 				}
 				break;
 
-			case PLPGSQL_DTYPE_ROW:
 			case PLPGSQL_DTYPE_REC:
+				{
+					PLpgSQL_rec *rec = (PLpgSQL_rec *) retvar;
+					int32		rettypmod;
+
+					/*
+					 * Check if the record's composite type was altered since
+					 * the record was populated. If so, raise an error to
+					 * prevent crashes when outputting the record.
+					 */
+					if (rec->erh != NULL &&
+						!ExpandedRecordIsEmpty(rec->erh))
+						check_record_type_not_altered(rec);
+
+					exec_eval_datum(estate,
+									retvar,
+									&estate->rettype,
+									&rettypmod,
+									&estate->retval,
+									&estate->retisnull);
+				}
+				break;
+
+			case PLPGSQL_DTYPE_ROW:
 				{
 					/* exec_eval_datum can handle these cases */
 					int32		rettypmod;
@@ -3434,6 +3462,14 @@ exec_stmt_return_next(PLpgSQL_execstate *estate,
 					TupleDesc	rec_tupdesc;
 					TupleConversionMap *tupmap;
 
+					/*
+					 * Check if the record's composite type was altered since
+					 * the record was populated. If so, raise an error to
+					 * prevent crashes when storing to the tuplestore.
+					 */
+					if (rec->erh != NULL && !ExpandedRecordIsEmpty(rec->erh))
+						check_record_type_not_altered(rec);
+
 					/* If rec is null, try to convert it to a row of nulls */
 					if (rec->erh == NULL)
 						instantiate_empty_record_variable(estate, rec);
@@ -5451,6 +5487,14 @@ exec_eval_datum(PLpgSQL_execstate *estate,
 				}
 				else
 				{
+					/*
+					 * Check if the record's composite type was altered
+					 * since the record was populated.  This catches all
+					 * output paths: RETURN, RAISE, EXECUTE USING, etc.
+					 */
+					if (!ExpandedRecordIsEmpty(rec->erh))
+						check_record_type_not_altered(rec);
+
 					if (ExpandedRecordIsEmpty(rec->erh))
 					{
 						/* Empty record is also a NULL */
@@ -7042,6 +7086,14 @@ exec_move_row(PLpgSQL_execstate *estate,
 				if (rec->erh)
 					DeleteExpandedObject(ExpandedRecordGetDatum(rec->erh));
 				rec->erh = NULL;
+				/* Clear composite type snapshot */
+				if (rec->compTypeOids)
+					pfree(rec->compTypeOids);
+				if (rec->compTypeVersions)
+					pfree(rec->compTypeVersions);
+				rec->nCompTypes = 0;
+				rec->compTypeOids = NULL;
+				rec->compTypeVersions = NULL;
 			}
 			return;
 		}
@@ -7745,6 +7797,13 @@ exec_move_row_from_datum(PLpgSQL_execstate *estate,
 			{
 				expanded_record_set_tuple(rec->erh, erh->fvalue,
 										  true, !estate->atomic);
+				/*
+				 * The tuple bytes were replaced in place.  Refresh the
+				 * composite-type snapshot so a later ALTER TYPE on any
+				 * reachable type is detected, and stale snapshot entries
+				 * from a prior assignment are not carried forward.
+				 */
+				snapshot_record_composite_types(estate, rec);
 				return;
 			}
 
@@ -7859,6 +7918,8 @@ exec_move_row_from_datum(PLpgSQL_execstate *estate,
 			{
 				expanded_record_set_tuple(rec->erh, &tmptup,
 										  true, !estate->atomic);
+				/* See comment in the analogous branch above. */
+				snapshot_record_composite_types(estate, rec);
 				return;
 			}
 
@@ -7925,6 +7986,9 @@ instantiate_empty_record_variable(PLpgSQL_execstate *estate, PLpgSQL_rec *rec)
 	/* OK, do it */
 	rec->erh = make_expanded_record_from_typeid(rec->rectypeid, -1,
 												estate->datum_context);
+
+	/* Snapshot composite type versions for ALTER TYPE detection */
+	snapshot_record_composite_types(estate, rec);
 }
 
 /* ----------
@@ -8967,6 +9031,9 @@ assign_record_var(PLpgSQL_execstate *estate, PLpgSQL_rec *rec,
 
 	/* ... and install the new */
 	rec->erh = erh;
+
+	/* Snapshot composite type versions for ALTER TYPE detection */
+	snapshot_record_composite_types(estate, rec);
 }
 
 /*
@@ -9216,3 +9283,258 @@ format_preparedparamsdata(PLpgSQL_execstate *estate,
 
 	return paramstr.data;
 }
+
+/*
+ * check_record_type_not_altered
+ *
+ * Check if any composite type reachable from this record's type has been
+ * altered since the record was populated.  If so, raise an error to prevent
+ * crashes that would occur when outputting data that no longer matches the
+ * current type definition.
+ *
+ * The outermost type is always checked using er_tupdesc_id (which is set
+ * when the ExpandedRecord is created and works regardless of how the record
+ * was populated, whether by whole assignment, field assignment, etc.).
+ *
+ * Nested composite types are checked against the snapshot taken at record
+ * assignment time, if available.
+ */
+static void
+check_record_type_not_altered(PLpgSQL_rec *rec)
+{
+	TypeCacheEntry *typentry;
+	Oid			check_typid;
+	int			i;
+
+	/*
+	 * Determine the effective type to check.  For a variable declared as
+	 * generic RECORD, use the ExpandedRecord's actual type once it's been
+	 * assigned.  If that too is RECORDOID (truly anonymous rowtype), there
+	 * is nothing to check because such rowtypes are not versioned.
+	 */
+	if (rec->rectypeid != RECORDOID)
+		check_typid = rec->rectypeid;
+	else if (rec->erh != NULL && rec->erh->er_typeid != RECORDOID)
+		check_typid = rec->erh->er_typeid;
+	else
+		return;
+
+	/*
+	 * Always check outermost type using er_tupdesc_id.  This works for all
+	 * code paths (whole assignment, field assignment, SELECT INTO, etc.)
+	 * because er_tupdesc_id is set when the ExpandedRecord is created.
+	 * Resolve domain types to their base composite type first.
+	 */
+	if (get_typtype(check_typid) == TYPTYPE_DOMAIN)
+		check_typid = getBaseType(check_typid);
+
+	typentry = lookup_type_cache(check_typid, TYPECACHE_TUPDESC);
+
+	if (rec->erh->er_tupdesc_id != typentry->tupDesc_identifier)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATATYPE_MISMATCH),
+				 errmsg("cannot return record variable \"%s\" after composite type \"%s\" was altered",
+						rec->refname,
+						format_type_be(check_typid))));
+
+	/*
+	 * If we have a snapshot of nested composite types (taken at whole-record
+	 * assignment time), check those too.  Skip index 0 since that's the
+	 * outermost type we already checked above.
+	 */
+	for (i = 1; i < rec->nCompTypes; i++)
+	{
+		typentry = lookup_type_cache(rec->compTypeOids[i], TYPECACHE_TUPDESC);
+
+		if (typentry->tupDesc_identifier != rec->compTypeVersions[i])
+			ereport(ERROR,
+					(errcode(ERRCODE_DATATYPE_MISMATCH),
+					 errmsg("cannot return record variable \"%s\" after composite type \"%s\" was altered",
+							rec->refname,
+							format_type_be(rec->compTypeOids[i]))));
+	}
+}
+
+/*
+ * collect_composite_type_versions
+ *
+ * Recursively collect tupDesc_identifier values for a composite type and
+ * all composite types reachable from its attributes.  Skips anonymous
+ * RECORD types and types already recorded (to prevent infinite recursion).
+ *
+ * oids/versions arrays are repalloc'd as needed; n/alloc updated in place.
+ */
+static void
+collect_composite_type_versions(Oid typid,
+								Oid **oids, uint64 **versions,
+								int *n, int *alloc)
+{
+	TypeCacheEntry *typentry;
+	TupleDesc	tupdesc;
+	int			i;
+
+	/* Resolve domain types to their base composite type */
+	if (get_typtype(typid) == TYPTYPE_DOMAIN)
+		typid = getBaseType(typid);
+
+	/* Skip if already recorded */
+	for (i = 0; i < *n; i++)
+	{
+		if ((*oids)[i] == typid)
+			return;
+	}
+
+	typentry = lookup_type_cache(typid, TYPECACHE_TUPDESC);
+
+	/* Grow arrays if needed */
+	if (*n >= *alloc)
+	{
+		*alloc *= 2;
+		*oids = repalloc(*oids, *alloc * sizeof(Oid));
+		*versions = repalloc(*versions, *alloc * sizeof(uint64));
+	}
+
+	(*oids)[*n] = typid;
+	(*versions)[*n] = typentry->tupDesc_identifier;
+	(*n)++;
+
+	tupdesc = typentry->tupDesc;
+	if (tupdesc == NULL)
+		return;
+
+	/* Recurse into composite-type attributes */
+	for (i = 0; i < tupdesc->natts; i++)
+	{
+		Form_pg_attribute attr = TupleDescAttr(tupdesc, i);
+		Oid			attrtypid;
+
+		if (attr->attisdropped)
+			continue;
+
+		attrtypid = attr->atttypid;
+
+		/*
+		 * A record field can reference a composite type directly, or wrap
+		 * one inside a domain or a container type (array, range, or
+		 * multirange).  ALTER TYPE on such an indirectly-referenced
+		 * composite must still be detected, so peel off domain and
+		 * container layers until we reach a composite type (which we
+		 * snapshot) or something that cannot contain one (which we skip).
+		 * This terminates because each peel step yields a strictly
+		 * "smaller" type.  Container element types cannot be self-referential.
+		 */
+		for (;;)
+		{
+			char		typtype = get_typtype(attrtypid);
+			Oid			elemtypid;
+
+			if (attrtypid == RECORDOID)
+				break;			/* anonymous rowtype: not versionable */
+
+			if (typtype == TYPTYPE_DOMAIN)
+			{
+				attrtypid = getBaseType(attrtypid);
+				continue;
+			}
+
+			if (typtype == TYPTYPE_COMPOSITE)
+			{
+				collect_composite_type_versions(attrtypid,
+												oids, versions, n, alloc);
+				break;
+			}
+
+			/* Array element type? */
+			elemtypid = get_element_type(attrtypid);
+
+			/* Otherwise a range's subtype? */
+			if (!OidIsValid(elemtypid))
+				elemtypid = get_range_subtype(attrtypid);
+
+			/* Otherwise a multirange's range's subtype? */
+			if (!OidIsValid(elemtypid) && typtype == TYPTYPE_MULTIRANGE)
+			{
+				Oid			rangetypid = get_multirange_range(attrtypid);
+
+				if (OidIsValid(rangetypid))
+					elemtypid = get_range_subtype(rangetypid);
+			}
+
+			if (!OidIsValid(elemtypid))
+				break;			/* not a composite-bearing type */
+
+			attrtypid = elemtypid;
+		}
+	}
+}
+
+/*
+ * snapshot_record_composite_types
+ *
+ * Take a snapshot of tupDesc_identifier values for all composite types
+ * reachable from the record's declared type.  Called when a record variable
+ * is assigned a new value, so that check_record_type_not_altered() can
+ * detect mid-transaction ALTER TYPE at RETURN time.
+ *
+ * For a variable declared as generic RECORD (rectypeid == RECORDOID), the
+ * effective root type is taken from the ExpandedRecord's er_typeid, which
+ * reflects the actual composite type adopted from the assigned value.  If
+ * that too is RECORDOID (truly anonymous rowtype), no snapshot is taken
+ * because such rowtypes are not versioned by the type cache.
+ */
+static void
+snapshot_record_composite_types(PLpgSQL_execstate *estate,
+								PLpgSQL_rec *rec)
+{
+	MemoryContext oldcxt;
+	Oid			root_typid;
+	int			alloc = 8;
+	int			n = 0;
+	Oid		   *oids;
+	uint64	   *versions;
+
+	/*
+	 * Determine the effective root type.  For a variable declared as generic
+	 * RECORD, use the ExpandedRecord's actual type once it's been assigned.
+	 * Truly anonymous rowtypes (er_typeid still RECORDOID) cannot be tracked.
+	 */
+	if (rec->rectypeid != RECORDOID)
+		root_typid = rec->rectypeid;
+	else if (rec->erh != NULL && rec->erh->er_typeid != RECORDOID)
+		root_typid = rec->erh->er_typeid;
+	else
+	{
+		rec->nCompTypes = 0;
+		return;
+	}
+
+	oldcxt = MemoryContextSwitchTo(estate->datum_context);
+	oids = palloc(alloc * sizeof(Oid));
+	versions = palloc(alloc * sizeof(uint64));
+
+	collect_composite_type_versions(root_typid,
+									&oids, &versions, &n, &alloc);
+
+	MemoryContextSwitchTo(oldcxt);
+
+	if (n > 0)
+	{
+		/* Free previous snapshot if any */
+		if (rec->compTypeOids)
+			pfree(rec->compTypeOids);
+		if (rec->compTypeVersions)
+			pfree(rec->compTypeVersions);
+
+		rec->nCompTypes = n;
+		rec->compTypeOids = oids;
+		rec->compTypeVersions = versions;
+	}
+	else
+	{
+		pfree(oids);
+		pfree(versions);
+		rec->nCompTypes = 0;
+		rec->compTypeOids = NULL;
+		rec->compTypeVersions = NULL;
+	}
+}
diff --git a/src/pl/plpgsql/src/plpgsql.h b/src/pl/plpgsql/src/plpgsql.h
index addb14a9959..cf9a657613d 100644
--- a/src/pl/plpgsql/src/plpgsql.h
+++ b/src/pl/plpgsql/src/plpgsql.h
@@ -435,6 +435,15 @@ typedef struct PLpgSQL_rec
 
 	/* We always store record variables as "expanded" records */
 	ExpandedRecordHeader *erh;
+
+	/*
+	 * Composite type version snapshot for ALTER TYPE detection.
+	 * Populated when the record is assigned; checked at RETURN time.
+	 * Includes the outermost type and all nested composite types.
+	 */
+	int			nCompTypes;
+	Oid		   *compTypeOids;
+	uint64	   *compTypeVersions;
 } PLpgSQL_rec;
 
 /*
diff --git a/src/pl/plpgsql/src/sql/plpgsql_record.sql b/src/pl/plpgsql/src/sql/plpgsql_record.sql
index 4fbed38b8bb..cc1406a663d 100644
--- a/src/pl/plpgsql/src/sql/plpgsql_record.sql
+++ b/src/pl/plpgsql/src/sql/plpgsql_record.sql
@@ -577,3 +577,239 @@ insert into two_int8s_tab values (compresult(42));
 -- reconnect so we lose any local knowledge of anonymous record types
 \c -
 table two_int8s_tab;
+
+-- Tests for bug #19382: server crash when ALTER TYPE is used mid-transaction
+-- in PL/pgSQL. Record variables populated before ALTER TYPE must not be
+-- returned, as the stored data no longer matches the current type definition.
+
+-- Case 1: Direct composite type change (INT -> TEXT)
+create type bug19382_foo as (a int, b int);
+create function bug19382_test_direct() returns record as $$
+declare r bug19382_foo := row(123, power(2, 30));
+begin
+    alter type bug19382_foo alter attribute b type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_direct();
+drop function bug19382_test_direct();
+drop type bug19382_foo cascade;
+
+-- Case 2: Nested composite type change
+create type bug19382_inner as (x int, y int);
+create type bug19382_outer as (a int, b bug19382_inner);
+create function bug19382_test_nested() returns record as $$
+declare r bug19382_outer;
+begin
+    r := row(1, row(10, power(2, 30)::int4)::bug19382_inner)::bug19382_outer;
+    alter type bug19382_inner alter attribute y type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_nested();
+drop function bug19382_test_nested();
+drop type bug19382_outer cascade;
+drop type bug19382_inner cascade;
+
+-- Case 3: OUT parameter
+create type bug19382_foo1 as (a int, b int);
+create function bug19382_test_out(out r1 bug19382_foo1) as $$
+begin
+    r1 := row(1, 2);
+    alter type bug19382_foo1 alter attribute b type text;
+    return;
+end;
+$$ language plpgsql;
+select bug19382_test_out();
+drop function bug19382_test_out();
+drop type bug19382_foo1 cascade;
+
+-- Case 4: No ALTER TYPE (baseline — must not error)
+create type bug19382_foo2 as (a int, b int);
+create function bug19382_test_baseline() returns bug19382_foo2 as $$
+declare r bug19382_foo2 := row(1, 2);
+begin
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_baseline();
+drop function bug19382_test_baseline();
+drop type bug19382_foo2;
+
+-- Case 5: Field-by-field assignment (dot notation)
+create type bug19382_foo3 as (a int, b int);
+create function bug19382_test_field_assign() returns record as $$
+declare r bug19382_foo3;
+begin
+    r.a := 123;
+    r.b := power(2, 30)::int4;
+    alter type bug19382_foo3 alter attribute b type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_field_assign();
+drop function bug19382_test_field_assign();
+drop type bug19382_foo3 cascade;
+
+-- Case 6: SELECT INTO individual fields
+create type bug19382_foo4 as (a int, b int);
+create table bug19382_tbl (a int, b int);
+insert into bug19382_tbl values (123, power(2, 30)::int4);
+create function bug19382_test_select_into_field() returns record as $$
+declare r bug19382_foo4;
+begin
+    select a, b into r.a, r.b from bug19382_tbl;
+    alter type bug19382_foo4 alter attribute b type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_select_into_field();
+drop function bug19382_test_select_into_field();
+drop table bug19382_tbl;
+drop type bug19382_foo4 cascade;
+
+-- Case 7: RAISE NOTICE with record variable (exec_eval_datum path)
+create type bug19382_foo5 as (a int, b int);
+create function bug19382_test_eval_datum() returns void as $$
+declare r bug19382_foo5;
+begin
+    r.b := power(2, 30)::int4;
+    alter type bug19382_foo5 alter attribute b type text;
+    raise notice 'r = %', r;
+end;
+$$ language plpgsql;
+select bug19382_test_eval_datum();
+drop function bug19382_test_eval_datum();
+drop type bug19382_foo5 cascade;
+
+-- Case 8: Generic RECORD variable assigned via ROW::foo cast
+-- The declared type is RECORDOID; effective type is discovered from the
+-- assigned value's er_typeid.  Must error, not crash.
+create type bug19382_foo6 as (a int, b int);
+create function bug19382_test_generic_record() returns record as $$
+declare r record;
+begin
+    r := row(123, power(2, 30)::int4)::bug19382_foo6;
+    alter type bug19382_foo6 alter attribute b type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_generic_record();
+drop function bug19382_test_generic_record();
+drop type bug19382_foo6 cascade;
+
+-- Case 9: Generic RECORD with nested composite alter
+create type bug19382_inner2 as (x int, y int);
+create type bug19382_outer2 as (a int, b bug19382_inner2);
+create function bug19382_test_generic_nested() returns record as $$
+declare r record;
+begin
+    r := row(1, row(10, power(2, 30)::int4)::bug19382_inner2)::bug19382_outer2;
+    alter type bug19382_inner2 alter attribute y type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_generic_nested();
+drop function bug19382_test_generic_nested();
+drop type bug19382_outer2 cascade;
+drop type bug19382_inner2 cascade;
+
+-- Case 10: Whole-record reassignment after inner-type alter.
+-- The second assignment builds fresh data matching the post-ALTER type;
+-- the snapshot must refresh so the reassignment succeeds.
+create type bug19382_inner3 as (x int, y int);
+create type bug19382_outer3 as (a int, b bug19382_inner3);
+create function bug19382_test_reassign() returns bug19382_outer3 as $$
+declare r bug19382_outer3;
+begin
+    r := row(1, row(10, 20)::bug19382_inner3)::bug19382_outer3;
+    alter type bug19382_inner3 alter attribute y type text;
+    r := row(1, row(10, 'hello')::bug19382_inner3)::bug19382_outer3;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_reassign();
+drop function bug19382_test_reassign();
+drop type bug19382_outer3 cascade;
+drop type bug19382_inner3 cascade;
+
+-- Case 11: Anonymous rowtype baseline (RECORDOID with no ::foo cast).
+-- Non-versionable rowtypes must not trigger a false positive.
+create function bug19382_test_anon_baseline() returns record as $$
+declare r record;
+begin
+    select 1 as a, 2 as b into r;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_anon_baseline();
+drop function bug19382_test_anon_baseline();
+
+-- Case 12: Variable-to-variable copy after inner-type alter.
+-- Reading r1 as an rvalue must detect the stale nested type.
+create type bug19382_inner4 as (x int, y int);
+create type bug19382_outer4 as (a int, b bug19382_inner4);
+create function bug19382_test_var_copy() returns bug19382_outer4 as $$
+declare r1 bug19382_outer4; r2 bug19382_outer4;
+begin
+    r1 := row(1, row(10, power(2, 30)::int4)::bug19382_inner4)::bug19382_outer4;
+    alter type bug19382_inner4 alter attribute y type text;
+    r2 := r1;
+    return r2;
+end;
+$$ language plpgsql;
+select bug19382_test_var_copy();
+drop function bug19382_test_var_copy();
+drop type bug19382_outer4 cascade;
+drop type bug19382_inner4 cascade;
+
+-- Case 13: RAISE NOTICE with generic RECORD (RECORDOID + reader path).
+create type bug19382_foo7 as (a int, b int);
+create function bug19382_test_generic_raise() returns void as $$
+declare r record;
+begin
+    r := row(1, power(2, 30)::int4)::bug19382_foo7;
+    alter type bug19382_foo7 alter attribute b type text;
+    raise notice 'r = %', r;
+end;
+$$ language plpgsql;
+select bug19382_test_generic_raise();
+drop function bug19382_test_generic_raise();
+drop type bug19382_foo7 cascade;
+
+-- Case 14: composite type reachable through an array attribute.
+-- The element composite type must be tracked even though the attribute's
+-- own type is an array; otherwise ALTER TYPE on the element goes undetected
+-- and record_out() crashes reinterpreting the stored bytes.
+create type bug19382_elem as (f1 int);
+create type bug19382_arrparent as (arr bug19382_elem[]);
+create function bug19382_test_array_elem() returns bug19382_arrparent as $$
+declare r bug19382_arrparent;
+begin
+    r := row(array[row(power(2, 30)::int4)::bug19382_elem]::bug19382_elem[])::bug19382_arrparent;
+    alter type bug19382_elem alter attribute f1 type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_array_elem();
+drop function bug19382_test_array_elem();
+drop type bug19382_arrparent cascade;
+drop type bug19382_elem cascade;
+
+-- Case 15: array element composite, whole-record reassignment after ALTER
+-- with fresh matching data must succeed (no false positive on arrays).
+create type bug19382_elem2 as (f1 int);
+create type bug19382_arrparent2 as (arr bug19382_elem2[]);
+create function bug19382_test_array_reassign() returns bug19382_arrparent2 as $$
+declare r bug19382_arrparent2;
+begin
+    r := row(array[row(1)::bug19382_elem2]::bug19382_elem2[])::bug19382_arrparent2;
+    alter type bug19382_elem2 add attribute f2 text;
+    r := row(array[row(1, 'hi')::bug19382_elem2]::bug19382_elem2[])::bug19382_arrparent2;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_array_reassign();
+drop function bug19382_test_array_reassign();
+drop type bug19382_arrparent2 cascade;
+drop type bug19382_elem2 cascade;
-- 
2.39.5 (Apple Git-154)

