From 23236b505bd6b57f92518a4324ee19c8bd86f5b5 Mon Sep 17 00:00:00 2001
From: TatsuyaKawata <kawatatatsuya0913@gmail.com>
Date: Sun, 4 Oct 2026 19:55:09 +0900
Subject: [PATCH v2 2/2] 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.

A rescan with changed parameters replaces a function's tuplestore with a
new one, so for each function we take the largest of its current
tuplestore and the ones discarded by earlier rescans.  This way the
reported value doesn't depend on which call happened to come last.

To let explain.c reach this per-function state, 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            | 68 +++++++++++++++++++++++
 src/backend/executor/nodeFunctionscan.c   | 20 -------
 src/include/nodes/execnodes.h             | 28 ++++++++--
 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, 161 insertions(+), 28 deletions(-)

diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 96f2f0e2e74..163d2bd7146 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,
@@ -2097,6 +2099,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)
@@ -3543,6 +3546,71 @@ 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++)
+	{
+		FunctionScanPerFuncState *fs = &fsstate->funcstates[i];
+		char	   *storageType = fs->savedStorageType;
+		int64		spaceUsed = fs->savedSpaceUsed;
+
+		/*
+		 * A rescan with changed parameters replaces the function's tuplestore
+		 * with a new one, so take the larger of the statistics of the current
+		 * one and those saved from the ones discarded earlier.
+		 */
+		if (fs->tstore != NULL)
+		{
+			char	   *curStorageType;
+			int64		curSpaceUsed;
+
+			tuplestore_get_stats(fs->tstore, &curStorageType, &curSpaceUsed);
+
+			if (curSpaceUsed > spaceUsed)
+			{
+				spaceUsed = curSpaceUsed;
+				storageType = curStorageType;
+			}
+		}
+
+		/* Execution may not have got as far as creating any tuplestore */
+		if (storageType == NULL)
+			continue;
+
+		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 bbdef76ca63..d180147f51b 100644
--- a/src/backend/executor/nodeFunctionscan.c
+++ b/src/backend/executor/nodeFunctionscan.c
@@ -30,26 +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) */
-
-	/*
-	 * Storage statistics of the largest tuplestore discarded by a rescan so
-	 * far.
-	 */
-	char	   *savedStorageType;
-	int64		savedSpaceUsed;
-} FunctionScanPerFuncState;
-
 static TupleTableSlot *FunctionNext(FunctionScanState *node);
 static void save_tuplestore_stats(FunctionScanPerFuncState *fs);
 
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 91bb0bd2e13..b0d918d872c 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -1910,6 +1910,27 @@ 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) */
+
+	/*
+	 * Storage statistics of the largest tuplestore discarded by a rescan so
+	 * far.
+	 */
+	char	   *savedStorageType;
+	int64		savedSpaceUsed;
+} FunctionScanPerFuncState;
+
 /* ----------------
  *	 FunctionScanState information
  *
@@ -1921,13 +1942,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 */
@@ -1936,7 +1954,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

