Subject: Re: BUG #19382: Server crash at __nss_database_lookup

From: surya poondla <[email-masked]>
Date: Wed, 18 Mar 2026 22:00:41 -0700

Hi All,

I was able to reproduce the crash on laster master (19), the above patch
applies cleanly on postgres 19 and doesn't crash the server.

psql (19devel)
Type "help" for help.

postgres=# DROP FUNCTION IF EXISTS bar();
NOTICE:  function bar() does not exist, skipping
DROP FUNCTION
postgres=#   DROP TYPE IF EXISTS foo CASCADE;
NOTICE:  type "foo" does not exist, skipping
DROP TYPE
postgres=#   CREATE TYPE foo AS (a INT, b INT);
CREATE TYPE
postgres=#   CREATE FUNCTION bar() RETURNS RECORD AS $$
postgres$#   DECLARE
postgres$#       r foo := ROW(123, power(2, 30));
postgres$#   BEGIN
postgres$#       ALTER TYPE foo ALTER ATTRIBUTE b TYPE TEXT;
postgres$#       RETURN r;
postgres$#   END;
postgres$#   $$ LANGUAGE plpgsql;
CREATE FUNCTION
postgres=# SELECT bar();
       bar
------------------
 (123,1073741824)
(1 row)

postgres=# DROP FUNCTION IF EXISTS bar1();
NOTICE:  function bar1() does not exist, skipping
DROP FUNCTION
postgres=#   DROP TYPE IF EXISTS foo1 CASCADE;
NOTICE:  type "foo1" does not exist, skipping
DROP TYPE
postgres=#   CREATE TYPE foo1 AS (a INT, b INT);
CREATE TYPE
postgres=#   CREATE FUNCTION bar1(OUT r1 foo1) AS $$
postgres$#   BEGIN
postgres$#       r1 := ROW(1, 2);
postgres$#       ALTER TYPE foo1 ALTER ATTRIBUTE b TYPE TEXT;
postgres$#       RETURN;
postgres$#   END;
postgres$#   $$ LANGUAGE plpgsql;
CREATE FUNCTION
postgres=# SELECT bar1();
 bar1
-------
 (1,2)
(1 row)

postgres=# DROP FUNCTION IF EXISTS bar2();
NOTICE:  function bar2() does not exist, skipping
DROP FUNCTION
postgres=#   DROP TYPE IF EXISTS foo2 CASCADE;
NOTICE:  type "foo2" does not exist, skipping
DROP TYPE
postgres=#   CREATE TYPE foo2 AS (a INT, b TEXT);
CREATE TYPE
postgres=#   CREATE FUNCTION bar2() RETURNS foo2 AS $$
postgres$#   DECLARE
postgres$#       r foo2 := ROW(1, 'hello');
postgres$#   BEGIN
postgres$#       ALTER TYPE foo2 ALTER ATTRIBUTE b TYPE INT;
postgres$#       RETURN r;
postgres$#   END;
postgres$#   $$ LANGUAGE plpgsql;
CREATE FUNCTION
postgres=#   SELECT bar2();
ERROR:  invalid input syntax for type integer: "hello"
CONTEXT:  PL/pgSQL function bar2() line 6 at RETURN
postgres=# DROP FUNCTION bar();
DROP FUNCTION
postgres=#   DROP FUNCTION bar1();
DROP FUNCTION
postgres=#   DROP FUNCTION bar2();
DROP FUNCTION
postgres=#   DROP TYPE IF EXISTS foo CASCADE;
DROP TYPE
postgres=#   DROP TYPE IF EXISTS foo1 CASCADE;
DROP TYPE
postgres=#   DROP TYPE IF EXISTS foo2 CASCADE;
DROP TYPE
postgres=# quit

Regards,
Surya Poondla

---

From: "songjinzhou" <[email-masked]>
Date: Thu, 19 Mar 2026 15:53:48 +0800

Hi Surya Poondla:

After applying the patch on the master branch, I debugged it and the type mismatch issue is indeed not as bad as before. I haven't looked at this patch much, but I'm a little worried about performance issues here.

I have one more question: Can we iterate through the code first to get the value of `need_conversion`, and then allocate memory and perform subsequent operations only if necessary? Of course, this is just my opinion. Thank you.

Regards,
songjinzhou

songjinzhou
[email-masked]

         原始邮件

发件人：surya poondla <[email-masked]&gt;
发件时间：2026年3月19日 13:00
收件人：dllggyx <[email-masked]&gt;, pgsql-bugs <[email-masked]&gt;
主题：Re: BUG #19382: Server crash at __nss_database_lookup

Hi All,

I was able to reproduce the crash on laster master (19), the above patch applies cleanly on postgres 19 and doesn't crash the server.

psql (19devel)
Type "help" for help.

postgres=# DROP FUNCTION IF EXISTS bar();
NOTICE: &nbsp;function bar() does not exist, skipping
DROP FUNCTION
postgres=# &nbsp; DROP TYPE IF EXISTS foo CASCADE;
NOTICE: &nbsp;type "foo" does not exist, skipping
DROP TYPE
postgres=# &nbsp; CREATE TYPE foo AS (a INT, b INT);
CREATE TYPE
postgres=# &nbsp; CREATE FUNCTION bar() RETURNS RECORD AS $$
postgres$# &nbsp; DECLARE
postgres$# &nbsp; &nbsp; &nbsp; r foo := ROW(123, power(2, 30));
postgres$# &nbsp; BEGIN
postgres$# &nbsp; &nbsp; &nbsp; ALTER TYPE foo ALTER ATTRIBUTE b TYPE TEXT;
postgres$# &nbsp; &nbsp; &nbsp; RETURN r;
postgres$# &nbsp; END;
postgres$# &nbsp; $$ LANGUAGE plpgsql;
CREATE FUNCTION
postgres=# SELECT bar();
&nbsp; &nbsp; &nbsp; &nbsp;bar
------------------
&nbsp;(123,1073741824)
(1 row)

postgres=# DROP FUNCTION IF EXISTS bar1();
NOTICE: &nbsp;function bar1() does not exist, skipping
DROP FUNCTION
postgres=# &nbsp; DROP TYPE IF EXISTS foo1 CASCADE;
NOTICE: &nbsp;type "foo1" does not exist, skipping
DROP TYPE
postgres=# &nbsp; CREATE TYPE foo1 AS (a INT, b INT);
CREATE TYPE
postgres=# &nbsp; CREATE FUNCTION bar1(OUT r1 foo1) AS $$
postgres$# &nbsp; BEGIN
postgres$# &nbsp; &nbsp; &nbsp; r1 := ROW(1, 2);
postgres$# &nbsp; &nbsp; &nbsp; ALTER TYPE foo1 ALTER ATTRIBUTE b TYPE TEXT;
postgres$# &nbsp; &nbsp; &nbsp; RETURN;
postgres$# &nbsp; END;
postgres$# &nbsp; $$ LANGUAGE plpgsql;
CREATE FUNCTION
postgres=# SELECT bar1();
&nbsp;bar1
-------
&nbsp;(1,2)
(1 row)

postgres=# DROP FUNCTION IF EXISTS bar2();
NOTICE: &nbsp;function bar2() does not exist, skipping
DROP FUNCTION
postgres=# &nbsp; DROP TYPE IF EXISTS foo2 CASCADE;
NOTICE: &nbsp;type "foo2" does not exist, skipping
DROP TYPE
postgres=# &nbsp; CREATE TYPE foo2 AS (a INT, b TEXT);
CREATE TYPE
postgres=# &nbsp; CREATE FUNCTION bar2() RETURNS foo2 AS $$
postgres$# &nbsp; DECLARE
postgres$# &nbsp; &nbsp; &nbsp; r foo2 := ROW(1, 'hello');
postgres$# &nbsp; BEGIN
postgres$# &nbsp; &nbsp; &nbsp; ALTER TYPE foo2 ALTER ATTRIBUTE b TYPE INT;
postgres$# &nbsp; &nbsp; &nbsp; RETURN r;
postgres$# &nbsp; END;
postgres$# &nbsp; $$ LANGUAGE plpgsql;
CREATE FUNCTION
postgres=# &nbsp; SELECT bar2();
ERROR: &nbsp;invalid input syntax for type integer: "hello"
CONTEXT: &nbsp;PL/pgSQL function bar2() line 6 at RETURN
postgres=# DROP FUNCTION bar();
DROP FUNCTION
postgres=# &nbsp; DROP FUNCTION bar1();
DROP FUNCTION
postgres=# &nbsp; DROP FUNCTION bar2();
DROP FUNCTION
postgres=# &nbsp; DROP TYPE IF EXISTS foo CASCADE;
DROP TYPE
postgres=# &nbsp; DROP TYPE IF EXISTS foo1 CASCADE;
DROP TYPE
postgres=# &nbsp; DROP TYPE IF EXISTS foo2 CASCADE;
DROP TYPE
postgres=# quit

Regards,
Surya Poondla

---

From: surya poondla <[email-masked]>
Date: Fri, 20 Mar 2026 11:16:16 -0700

Hi Songjinzhou,

Thank you for reviewing the patch.

You're correct that there is a minor inefficiency, once a
tupDesc_identifier version mismatch is detected, the patch allocates
new_values/new_nulls arrays and calls deconstruct_expanded_record() before
knowing whether any field types actually differ.
If the version changed but no field types changed (e.g., a constraint-only
ALTER), we do unnecessary work and hit the if (!need_conversion) return
late.

That said, your suggestion is clean and correct. I can restructure the code
to do a first pass over the TupleDesc attributes (which is pure metadata,
no deconstruction needed) to set need_conversion,
and only proceed with deconstruct_expanded_record() and array allocation if
that returns true. This avoids any unnecessary memory allocation in that
intermediate case.

I'll post an updated patch with this improvement.

Thanks again for the careful review!

Regards,
Surya Poondla

---

From: Andrey Borodin <[email-masked]>
Date: Thu, 2 Apr 2026 16:18:29 +0500

Hi!

Thanks for working on this!

> On 20 Mar 2026, at 23:16, surya poondla <[email-masked]> wrote:
> 

After your patch Postgres still crashes on this test:

CREATE TYPE foo AS (a INT, b INT);
BEGIN;
DECLARE c CURSOR FOR SELECT (i, power(2, 30))::foo FROM generate_series(1,10) i;
FETCH c;
ALTER TYPE foo ALTER ATTRIBUTE b TYPE TEXT;
FETCH c;
COMMIT;

This test case was proposed in this thread, but I suggest treating this as a separate bug needing separate fix.

In my opinion in both cases (PL/pgSQL + CURSOR) we should error out instead of trying to remediate type changes.

Best regards, Andrey Borodin.

---

From: surya poondla <[email-masked]>
Date: Thu, 2 Apr 2026 16:14:00 -0700

Hi All,

Thanks for the review Andrey.

> a separate bug needing separate fix.

Thank you for reporting this. Yes the cursor case can be treated as a
separate bug.
Though the 2 crash scenarios have the same root cause (record_out()
interpreting old data with new type definition) they require different fix
requirements.
1. PL/pgSQL case (this patch): ExpandedRecords already carry er_tupdesc_id
the version tracking infrastructure exists. The fix detects the mismatch
and converts the data. This is a self-contained bug fix using existing
mechanisms.
2. Cursor case: Flat HeapTuples carry no type version information, they
only have the type OID, which doesn't change after ALTER TYPE. Fixing this
requires adding new infrastructure that PostgreSQL doesn't have today
(e.g., storing tupDesc_identifier in Portal structures, or adding version
fields to HeapTupleHeaders). This is a broader architectural change that
affects core structures like PortalData, pquery.c, and potentially
portalmem.c. We need to see how to add version tracking to composite-type
values. I will work on this fix in parallel.

> In my opinion in both cases (PL/pgSQL + CURSOR) we should error out
> instead of trying to remediate type changes.
>
> I've simplified the fix. Instead of converting the record data, we now
raise a clear error when a composite type is altered mid-transaction after
the record was populated.
This also addresses the performance concern raised earlier since there's no
conversion logic at all now.

Updated patch attached.

Regards,
Surya Poondla

---

From: Andrey Borodin <[email-masked]>
Date: Sat, 4 Apr 2026 17:42:20 +0500

> On 3 Apr 2026, at 04:14, surya poondla <[email-masked]> wrote:
> 
> <0004-Fix-bug-19382-server-crash-when-ALTER-TYPE-is-used-m.patch>

Hi Surya,

Thanks for the updated patch. I noticed it checks er_tupdesc_id of the
outermost record variable, but does not recurse into nested composite types.
The server still crashes when only an inner type is altered:

CREATE TYPE inner_t AS (x INT, y INT);
CREATE TYPE outer_t AS (a INT, b inner_t);

CREATE OR REPLACE FUNCTION test_nested() RETURNS record LANGUAGE plpgsql AS $$
DECLARE r1 outer_t; r2 outer_t;
BEGIN
    r1 := ROW(1, ROW(10, power(2,30)::int4)::inner_t)::outer_t;
    ALTER TYPE inner_t ALTER ATTRIBUTE y TYPE TEXT;
    r2 := r1;
    RETURN r2;
END; $$;

SELECT test_nested();   -- server crash

The same gap exists on the cursor side independently of your patch, and I
have a fix for that part that walks the type tree recursively. IMO the
PL/pgSQL assignment path will need a similar recursive check.

I'm definitely not a big fan of checking types on every FETCH, but I see no
other ways around.

---

From: surya poondla <[email-masked]>
Date: Wed, 8 Apr 2026 21:24:06 -0700

Hi Andrey,

Thank you for the comments.

> Thank you for the nested composite testcase and the fix in cursor
code, the changes look good.. I fixed the PL/pgsql to fix the nested
components.

Here is the patch for the pl/pgsql fix

Regards,
Surya Poondla

---

From: surya poondla <[email-masked]>
Date: Tue, 14 Apr 2026 16:49:09 -0700

Hi All,

The latest CFBot run (v5 patch) shows one CI failure: FreeBSD - Meson:
test_misc/007_catcache_inval exits with status 29.
This failure is unrelated to the patch.
The failing test exercises catalog cache invalidation using injection
points on SQL function lookups, no PL/pgSQL, no record variables, no
composite type handling.

The patch only touches assign_record_var() in pl_exec.c and PLpgSQL_rec in
plpgsql.h, neither of which are executed by this test. All PL/pgSQL
regression tests pass on all platforms.

Re-attaching the patch to kick in the CFBot again.

Regards,
Surya Poondla

---

From: Andrey Borodin <[email-masked]>
Date: Thu, 16 Apr 2026 17:00:14 +0500

> On 9 Apr 2026, at 09:24, surya poondla <[email-masked]> wrote:
> 
> <0005-Fix-bug-19382-server-crash-when-ALTER-TYPE-is-used-m.patch>

I’ve took a look into the patch and here are some thoughts:

1. collect_composite_type_versions() does this
/* Resolve domain types to their base type */
if (typtype == TYPTYPE_DOMAIN)
{
			attrtypid = getBaseType(attrtypid);
			typtype = get_typtype(attrtypid);
}

Only for nested types. Do we need this for root?

2. When we do this

/* Clear composite type snapshot */
rec->nCompTypes = 0;
rec->compTypeOids = NULL;
rec->compTypeVersions = NULL;

We also might need two pfree()s.

3. Here are few other test cases that crash with the patch.

-- Case 5: Dot assignment
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 field also bypasses snapshot.
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;

Thanks!

---

From: surya poondla <[email-masked]>
Date: Thu, 16 Apr 2026 16:20:46 -0700

Hi Andrey,

Thank you for the detailed review and test cases. I've addressed all three
points in v6:

Point 1 (domain at root): collect_composite_type_versions() now resolves
domain types at the root level, not just for nested attributes. This
ensures the type tree walk works correctly when the record variable itself
is a domain over composite.

Point 2 (missing pfree): Added pfree() calls for compTypeOids and
compTypeVersions before NULLing them in the record-clear path.

Point 3 (Cases 5 and 6 i.e dot assignment and SELECT INTO fields): These
bypassed assign_record_var() because they modify the ExpandedRecord in
place via expanded_record_set_field(). The fix was to restructure
check_record_type_not_altered() into a two-level check:
  i) Outermost type: Always checked using erh->er_tupdesc_id, which is set
when the ExpandedRecord is created. This works for all code paths (whole
assignment, field assignment, SELECT INTO) without needing a snapshot.
  ii) Nested types: Checked against the snapshot when available (taken at
assign_record_var() and instantiate_empty_record_variable()).

I also added a fast-path optimization in snapshot_record_composite_types()
to avoid repeated type tree walks when a record is assigned in a loop — it
skips the snapshot if the outermost type's tupDesc_identifier hasn't
changed since the last snapshot.

Added both new test cases (Cases 5 and 6) to the regression. All 248 core
regression tests and all 13 PL/pgSQL tests pass.

Regards,
Surya Poondla

---

From: Andrey Borodin <[email-masked]>
Date: Fri, 17 Apr 2026 11:57:15 +0500

> On 17 Apr 2026, at 04:20, surya poondla <[email-masked]> wrote:
> 
> I also added a fast-path optimization in snapshot_record_composite_types() to avoid repeated type tree walks when a record is assigned in a loop — it skips the snapshot if the outermost type's tupDesc_identifier hasn't changed since the last snapshot.

Cool, but ISTM that it won’t work for domains.

if (rec->nCompTypes > 0 && rec->compTypeOids[0] == rec->rectypeid)

compTypeOids is composite, rectypeid is domain oid.
Can we make this work for domains too?

> Added both new test cases (Cases 5 and 6) to the regression. All 248 core regression tests and all 13 PL/pgSQL tests pass.

I hope it’s the last:

-- Case 7: composite variable used in RAISE NOTICE (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; -- exec_eval_datum called here, no check
end;
$$ language plpgsql;
select bug19382_test_eval_datum();
drop function bug19382_test_eval_datum();
drop type bug19382_foo5 cascade;

---

From: surya poondla <[email-masked]>
Date: Wed, 22 Apr 2026 21:17:29 -0700

Hi Andrey,

Thank you for identifying the domain fast-path bug and RAISE NOTICE. I
fixed both of them in the v7 patch.

Added the test 7 to the suite as well

Thank you again for helping in identifying further bugs and
providing feedback.

Regards,
Surya Poondla

---

From: surya poondla <[email-masked]>
Date: Tue, 2 Jun 2026 16:44:32 -0700

Hi All,

Rebased the patch to the latest code.

Regards,
Surya Poondla

---

From: surya poondla <[email-masked]>
Date: Tue, 30 Jun 2026 13:50:19 -0700

Hi All,

Regards,
Surya Poondla

---

From: Zsolt Parragi <[email-masked]>
Date: Mon, 6 Jul 2026 15:16:53 -0700

Hello!

The following still reproduces the crash with the patch:

create type foo as (a int, b int);
create function bar_rec() returns record as $$
declare r record;
begin
  r := row(123, power(2,30)::int4)::foo;
  alter type foo alter attribute b type text;
  return r;
end $$ language plpgsql;
select bar_rec();

And also, shouldn't the following still work?

create type inn2 as (x int, y int);
create type out2 as (a int, b inn2);
create function fp_test() returns out2 as $$
declare r out2;
begin
  r := row(1, row(10, 20)::inn2)::out2;
  alter type inn2 alter attribute y type text;
  r := row(1, row(10, 'hello')::inn2)::out2;
  return r;
end $$ language plpgsql;
select fp_test();
-- ERROR: cannot return record variable "r" after composite type
"inn2" was altered

but it only uses out2.

---

From: surya poondla <[email-masked]>
Date: Tue, 7 Jul 2026 16:20:55 -0700

Hi Zsolt,

Thanks for the testing, both issues you reported were real; v10 (attached)
fixes them.

Case 1 (plain "record" still crashed): the snapshot keyed off the
variable's declared type, which is just RECORDOID for a RECORD variable, so
nothing was tracked.
v10 uses the actual composite type adopted from the assigned value
(er_typeid), so "r record := ROW(...)::foo" is now covered.

Case 2 (reassignment wrongly rejected): a fast path only re-checked the
outermost type, so a stale nested entry survived a reassignment and caused
a false positive.
v10 refreshes the snapshot on every whole-record assignment, so reassigning
with fresh data after an ALTER now succeeds (matching master), while the
same sequence without the reassignment still errors.

While testing, I also found a related crash the earlier versions missed: a
composite reached through a container (e.g. a field of type "t_comp[]")
wasn't tracked, because the recursion stopped at the array. An ALTER TYPE
on the element type then crashed in record_out() and caused a connection
timeout.
v10 iterates domain/array/range/multirange layers to reach the composite
element.

v10 adds regression coverage for all of the above cases.