From fc16e2066521add3405e93e05e51a111ade9e967 Mon Sep 17 00:00:00 2001
From: TatsuyaKawata <kawatatatsuya0913@gmail.com>
Date: Sun, 16 Aug 2026 12:06:07 +0900
Subject: [PATCH v1] Add memory/disk usage for Function Scan nodes in EXPLAIN

Commit 1eff8279d4 added the infrastructure to report the maximum
tuplestore memory or disk usage with EXPLAIN ANALYZE, and 95d6e9af07 and
40708acd65 extended it to WindowAgg, CTE Scan, Table Function Scan and
Recursive Union nodes.  Function Scan was never covered, even though a
function in FROM always materializes its result into a tuplestore, and
that tuplestore can silently spill to disk once work_mem is exceeded.

Report the storage type and maximum storage used for Function Scan nodes
too.  A FunctionScan uses one tuplestore per function, so there can be
several of them when ROWS FROM is used.  As for Recursive Union, we
report the storage type of whichever one consumed the most memory or
disk, and the sum of the sizes of them all.

To let explain.c reach those tuplestores, move the definition of
FunctionScanPerFuncState from nodeFunctionscan.c to execnodes.h, where
the state structs of all the other nodes reporting this information
already live.
---
 src/backend/commands/explain.c            | 51 +++++++++++++++++++++++
 src/backend/executor/nodeFunctionscan.c   | 13 ------
 src/include/nodes/execnodes.h             | 21 +++++++---
 src/test/regress/expected/explain.out     | 50 ++++++++++++++++++++--
 src/test/regress/expected/planner_est.out |  4 ++
 src/test/regress/sql/explain.sql          | 15 +++++++
 src/test/regress/sql/planner_est.sql      |  4 ++
 7 files changed, 137 insertions(+), 21 deletions(-)

diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index f223c12f294..0c61174755c 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -130,6 +130,8 @@ static void show_hash_info(HashState *hashstate, ExplainState *es);
 static void show_material_info(MaterialState *mstate, ExplainState *es);
 static void show_windowagg_info(WindowAggState *winstate, ExplainState *es);
 static void show_ctescan_info(CteScanState *ctescanstate, ExplainState *es);
+static void show_functionscan_info(FunctionScanState *fsstate,
+								   ExplainState *es);
 static void show_table_func_scan_info(TableFuncScanState *tscanstate,
 									  ExplainState *es);
 static void show_recursive_union_info(RecursiveUnionState *rstate,
@@ -2100,6 +2102,7 @@ ExplainNode(PlanState *planstate, List *ancestors,
 			if (plan->qual)
 				show_instrumentation_count("Rows Removed by Filter", 1,
 										   planstate, es);
+			show_functionscan_info(castNode(FunctionScanState, planstate), es);
 			break;
 		case T_TableFuncScan:
 			if (es->verbose)
@@ -3546,6 +3549,54 @@ show_ctescan_info(CteScanState *ctescanstate, ExplainState *es)
 	show_storage_info(maxStorageType, maxSpaceUsed, es);
 }
 
+/*
+ * Show information on Function Scan node, storage method and maximum
+ * memory/disk space used.
+ */
+static void
+show_functionscan_info(FunctionScanState *fsstate, ExplainState *es)
+{
+	char	   *maxStorageType = NULL;
+	int64		maxSpaceUsed = -1;	/* negative so zero-sized stores count */
+	int64		totalSpaceUsed = 0;
+
+	if (!es->analyze)
+		return;
+
+	/*
+	 * A FunctionScan node uses one tuplestore per function, so there can be
+	 * more than one when ROWS FROM is used.  We employ the storage type from
+	 * whichever one consumed the most memory/disk, and the storage size is
+	 * the sum of them all, as we do for Recursive Union.
+	 */
+	for (int i = 0; i < fsstate->nfuncs; i++)
+	{
+		Tuplestorestate *tupstore = fsstate->funcstates[i].tstore;
+		char	   *storageType;
+		int64		spaceUsed;
+
+		/* Execution may not have got as far as creating this tuplestore */
+		if (tupstore == NULL)
+			continue;
+
+		tuplestore_get_stats(tupstore, &storageType, &spaceUsed);
+
+		totalSpaceUsed += spaceUsed;
+
+		if (spaceUsed > maxSpaceUsed)
+		{
+			maxSpaceUsed = spaceUsed;
+			maxStorageType = storageType;
+		}
+	}
+
+	/* Nothing to show if no tuplestore was created */
+	if (maxStorageType == NULL)
+		return;
+
+	show_storage_info(maxStorageType, totalSpaceUsed, es);
+}
+
 /*
  * Show information on Table Function Scan node, storage method and maximum
  * memory/disk space used.
diff --git a/src/backend/executor/nodeFunctionscan.c b/src/backend/executor/nodeFunctionscan.c
index 1416f1f09ae..29c521d82e1 100644
--- a/src/backend/executor/nodeFunctionscan.c
+++ b/src/backend/executor/nodeFunctionscan.c
@@ -30,19 +30,6 @@
 #include "utils/tuplestore.h"
 
 
-/*
- * Runtime data for each function being scanned.
- */
-typedef struct FunctionScanPerFuncState
-{
-	SetExprState *setexpr;		/* state of the expression being evaluated */
-	TupleDesc	tupdesc;		/* desc of the function result type */
-	int			colcount;		/* expected number of result columns */
-	Tuplestorestate *tstore;	/* holds the function result set */
-	int64		rowcount;		/* # of rows in result set, -1 if not known */
-	TupleTableSlot *func_slot;	/* function result slot (or NULL) */
-} FunctionScanPerFuncState;
-
 static TupleTableSlot *FunctionNext(FunctionScanState *node);
 
 
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index e95ac3eda35..facf90d65c4 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -1940,6 +1940,20 @@ typedef struct SubqueryScanState
 	PlanState  *subplan;
 } SubqueryScanState;
 
+/* ----------------
+ *		Runtime data for each function being scanned by a FunctionScan node.
+ * ----------------
+ */
+typedef struct FunctionScanPerFuncState
+{
+	SetExprState *setexpr;		/* state of the expression being evaluated */
+	TupleDesc	tupdesc;		/* desc of the function result type */
+	int			colcount;		/* expected number of result columns */
+	Tuplestorestate *tstore;	/* holds the function result set */
+	int64		rowcount;		/* # of rows in result set, -1 if not known */
+	TupleTableSlot *func_slot;	/* function result slot (or NULL) */
+} FunctionScanPerFuncState;
+
 /* ----------------
  *	 FunctionScanState information
  *
@@ -1951,13 +1965,10 @@ typedef struct SubqueryScanState
  *		simple				true if we have 1 function and no ordinality
  *		ordinal				current ordinal column value
  *		nfuncs				number of functions being executed
- *		funcstates			per-function execution states (private in
- *							nodeFunctionscan.c)
+ *		funcstates			per-function execution states
  *		argcontext			memory context to evaluate function arguments in
  * ----------------
  */
-struct FunctionScanPerFuncState;
-
 typedef struct FunctionScanState
 {
 	ScanState	ss;				/* its first field is NodeTag */
@@ -1966,7 +1977,7 @@ typedef struct FunctionScanState
 	bool		simple;
 	int64		ordinal;
 	int			nfuncs;
-	struct FunctionScanPerFuncState *funcstates;	/* array of length nfuncs */
+	FunctionScanPerFuncState *funcstates;	/* array of length nfuncs */
 	MemoryContext argcontext;
 } FunctionScanState;
 
diff --git a/src/test/regress/expected/explain.out b/src/test/regress/expected/explain.out
index 74a4d87801e..71348159522 100644
--- a/src/test/regress/expected/explain.out
+++ b/src/test/regress/expected/explain.out
@@ -799,9 +799,10 @@ select explain_filter('explain (analyze,buffers off,costs off) select sum(n) ove
    Window: w1 AS ()
    Storage: Memory  Maximum Storage: NkB
    ->  Function Scan on generate_series a (actual time=N.N..N.N rows=N.N loops=N)
+         Storage: Memory  Maximum Storage: NkB
  Planning Time: N.N ms
  Execution Time: N.N ms
-(6 rows)
+(7 rows)
 
 -- Test tuplestore storage usage in Window aggregate (disk case)
 set work_mem to 64;
@@ -812,9 +813,10 @@ select explain_filter('explain (analyze,buffers off,costs off) select sum(n) ove
    Window: w1 AS ()
    Storage: Disk  Maximum Storage: NkB
    ->  Function Scan on generate_series a (actual time=N.N..N.N rows=N.N loops=N)
+         Storage: Disk  Maximum Storage: NkB
  Planning Time: N.N ms
  Execution Time: N.N ms
-(6 rows)
+(7 rows)
 
 -- Test tuplestore storage usage in Window aggregate (memory and disk case, final result is disk)
 select explain_filter('explain (analyze,buffers off,costs off) select sum(n) over(partition by m) from (SELECT n < 3 as m, n from generate_series(1,2500) a(n))');
@@ -827,8 +829,50 @@ select explain_filter('explain (analyze,buffers off,costs off) select sum(n) ove
          Sort Key: ((a.n < N))
          Sort Method: external merge  Disk: NkB
          ->  Function Scan on generate_series a (actual time=N.N..N.N rows=N.N loops=N)
+               Storage: Disk  Maximum Storage: NkB
  Planning Time: N.N ms
  Execution Time: N.N ms
-(9 rows)
+(10 rows)
 
 reset work_mem;
+-- Ensure the Function Scan runs in the leader.  The storage information is
+-- taken from the leader's tuplestore, so a parallel plan would report
+-- nothing here.
+set max_parallel_workers_per_gather to 0;
+-- Test tuplestore storage usage in Function Scan (memory case)
+select explain_filter('explain (analyze,buffers off,costs off) select count(*) from generate_series(1,10) a(n)');
+                                  explain_filter                                  
+----------------------------------------------------------------------------------
+ Aggregate (actual time=N.N..N.N rows=N.N loops=N)
+   ->  Function Scan on generate_series a (actual time=N.N..N.N rows=N.N loops=N)
+         Storage: Memory  Maximum Storage: NkB
+ Planning Time: N.N ms
+ Execution Time: N.N ms
+(5 rows)
+
+-- Test tuplestore storage usage in Function Scan (disk case)
+set work_mem to 64;
+select explain_filter('explain (analyze,buffers off,costs off) select count(*) from generate_series(1,10000) a(n)');
+                                  explain_filter                                  
+----------------------------------------------------------------------------------
+ Aggregate (actual time=N.N..N.N rows=N.N loops=N)
+   ->  Function Scan on generate_series a (actual time=N.N..N.N rows=N.N loops=N)
+         Storage: Disk  Maximum Storage: NkB
+ Planning Time: N.N ms
+ Execution Time: N.N ms
+(5 rows)
+
+-- Test tuplestore storage usage in Function Scan with ROWS FROM, which uses
+-- one tuplestore per function
+select explain_filter('explain (analyze,buffers off,costs off) select count(*) from rows from (generate_series(1,10000), generate_series(1,10000)) a(n,m)');
+                          explain_filter                          
+------------------------------------------------------------------
+ Aggregate (actual time=N.N..N.N rows=N.N loops=N)
+   ->  Function Scan on a (actual time=N.N..N.N rows=N.N loops=N)
+         Storage: Disk  Maximum Storage: NkB
+ Planning Time: N.N ms
+ Execution Time: N.N ms
+(5 rows)
+
+reset work_mem;
+reset max_parallel_workers_per_gather;
diff --git a/src/test/regress/expected/planner_est.out b/src/test/regress/expected/planner_est.out
index 236cb274a78..fbe2d5ff1a1 100644
--- a/src/test/regress/expected/planner_est.out
+++ b/src/test/regress/expected/planner_est.out
@@ -29,6 +29,10 @@ BEGIN
         EXECUTE format('explain (analyze %s, costs on, summary off, timing off, buffers off) %s',
             analyze_str, query)
     LOOP
+        -- Ignore Storage output because the size varies depending on
+        -- the platform
+        CONTINUE WHEN ln ~ '^\s*Storage: ';
+
         IF hide_costs = true THEN
             ln := regexp_replace(ln, 'cost=\d+\.\d\d\.\.\d+\.\d\d', 'cost=N..N');
         END IF;
diff --git a/src/test/regress/sql/explain.sql b/src/test/regress/sql/explain.sql
index 2f163c64bf6..f7107407c20 100644
--- a/src/test/regress/sql/explain.sql
+++ b/src/test/regress/sql/explain.sql
@@ -191,3 +191,18 @@ select explain_filter('explain (analyze,buffers off,costs off) select sum(n) ove
 -- Test tuplestore storage usage in Window aggregate (memory and disk case, final result is disk)
 select explain_filter('explain (analyze,buffers off,costs off) select sum(n) over(partition by m) from (SELECT n < 3 as m, n from generate_series(1,2500) a(n))');
 reset work_mem;
+
+-- Ensure the Function Scan runs in the leader.  The storage information is
+-- taken from the leader's tuplestore, so a parallel plan would report
+-- nothing here.
+set max_parallel_workers_per_gather to 0;
+-- Test tuplestore storage usage in Function Scan (memory case)
+select explain_filter('explain (analyze,buffers off,costs off) select count(*) from generate_series(1,10) a(n)');
+-- Test tuplestore storage usage in Function Scan (disk case)
+set work_mem to 64;
+select explain_filter('explain (analyze,buffers off,costs off) select count(*) from generate_series(1,10000) a(n)');
+-- Test tuplestore storage usage in Function Scan with ROWS FROM, which uses
+-- one tuplestore per function
+select explain_filter('explain (analyze,buffers off,costs off) select count(*) from rows from (generate_series(1,10000), generate_series(1,10000)) a(n,m)');
+reset work_mem;
+reset max_parallel_workers_per_gather;
diff --git a/src/test/regress/sql/planner_est.sql b/src/test/regress/sql/planner_est.sql
index 2b696a4e4e5..72aae8807ec 100644
--- a/src/test/regress/sql/planner_est.sql
+++ b/src/test/regress/sql/planner_est.sql
@@ -30,6 +30,10 @@ BEGIN
         EXECUTE format('explain (analyze %s, costs on, summary off, timing off, buffers off) %s',
             analyze_str, query)
     LOOP
+        -- Ignore Storage output because the size varies depending on
+        -- the platform
+        CONTINUE WHEN ln ~ '^\s*Storage: ';
+
         IF hide_costs = true THEN
             ln := regexp_replace(ln, 'cost=\d+\.\d\d\.\.\d+\.\d\d', 'cost=N..N');
         END IF;
-- 
2.34.1

