From 461b88423d629bd2dc4175a1bcded75b78a80a60 Mon Sep 17 00:00:00 2001
From: Jakub Wartak <jakub.wartak@enterprisedb.com>
Date: Tue, 24 Mar 2026 07:42:58 +0100
Subject: [PATCH v1 1/2] Add "COPY TO BLACKHOLE"

Add BLACKHOLE COPY mode which saves some CPU cycles when not outputting
data anywhere which is helpful when trying to assess if relation(s) are
free of corruption. Follow-up commit will teach pg_dump to use this
in it's parallel (-j) pg_dump mode.

Author: Jakub Wartak <jakub.wartak@enterprisedb.com>
Reviewed-by:
Discussion:
---
 src/backend/commands/copy.c                   |  6 +++++
 src/backend/commands/copyto.c                 | 24 +++++++++++++++--
 src/backend/parser/gram.y                     | 27 ++++++++++++++++---
 src/include/commands/copy.h                   |  1 +
 src/include/commands/progress.h               |  1 +
 src/include/nodes/parsenodes.h                |  1 +
 src/include/parser/kwlist.h                   |  1 +
 .../test_copy_callbacks/test_copy_callbacks.c |  2 +-
 src/test/regress/expected/copy.out            |  9 +++++++
 src/test/regress/sql/copy.sql                 |  9 +++++++
 10 files changed, 75 insertions(+), 6 deletions(-)

diff --git a/src/backend/commands/copy.c b/src/backend/commands/copy.c
index 003b70852bb..3090fa04433 100644
--- a/src/backend/commands/copy.c
+++ b/src/backend/commands/copy.c
@@ -110,6 +110,11 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
 		}
 	}
 
+	if (stmt->is_blackhole && is_from)
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("COPY FROM BLACKHOLE is not supported")));
+
 	if (stmt->relation)
 	{
 		LOCKMODE	lockmode = is_from ? RowExclusiveLock : AccessShareLock;
@@ -376,6 +381,7 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
 
 		cstate = BeginCopyTo(pstate, rel, query, relid,
 							 stmt->filename, stmt->is_program,
+							 stmt->is_blackhole,
 							 NULL, stmt->attlist, stmt->options);
 		*processed = DoCopyTo(cstate);	/* copy from database to file */
 		EndCopyTo(cstate);
diff --git a/src/backend/commands/copyto.c b/src/backend/commands/copyto.c
index ffed63a2986..6dba2d712a0 100644
--- a/src/backend/commands/copyto.c
+++ b/src/backend/commands/copyto.c
@@ -51,6 +51,7 @@ typedef enum CopyDest
 	COPY_FILE,					/* to file (or a piped program) */
 	COPY_FRONTEND,				/* to frontend */
 	COPY_CALLBACK,				/* to callback function */
+	COPY_BLACKHOLE,				/* to nowhere */
 } CopyDest;
 
 /*
@@ -88,6 +89,7 @@ typedef struct CopyToStateData
 	List	   *attnumlist;		/* integer list of attnums to copy */
 	char	   *filename;		/* filename, or NULL for STDOUT */
 	bool		is_program;		/* is 'filename' a program to popen? */
+	bool		is_blackhole;	/* is destination BLACKHOLE? */
 	bool		json_row_delim_needed;	/* need delimiter before next row */
 	StringInfo	json_buf;		/* reusable buffer for JSON output,
 								 * initialized in BeginCopyTo */
@@ -631,6 +633,8 @@ CopySendEndOfRow(CopyToState cstate)
 		case COPY_CALLBACK:
 			cstate->data_dest_cb(fe_msgbuf->data, fe_msgbuf->len);
 			break;
+		case COPY_BLACKHOLE:
+			break;
 	}
 
 	/* Update the progress */
@@ -772,6 +776,7 @@ BeginCopyTo(ParseState *pstate,
 			Oid queryRelId,
 			const char *filename,
 			bool is_program,
+			bool is_blackhole,
 			copy_data_dest_cb data_dest_cb,
 			List *attnamelist,
 			List *options)
@@ -1118,8 +1123,14 @@ BeginCopyTo(ParseState *pstate,
 	cstate->encoding_embeds_ascii = PG_ENCODING_IS_CLIENT_ONLY(cstate->file_encoding);
 
 	cstate->copy_dest = COPY_FILE;	/* default */
+	cstate->is_blackhole = is_blackhole;
 
-	if (data_dest_cb)
+	if (is_blackhole)
+	{
+		progress_vals[1] = PROGRESS_COPY_TYPE_BLACKHOLE;
+		cstate->copy_dest = COPY_BLACKHOLE;
+	}
+	else if (data_dest_cb)
 	{
 		progress_vals[1] = PROGRESS_COPY_TYPE_CALLBACK;
 		cstate->copy_dest = COPY_CALLBACK;
@@ -1240,7 +1251,7 @@ EndCopyTo(CopyToState cstate)
 uint64
 DoCopyTo(CopyToState cstate)
 {
-	bool		pipe = (cstate->filename == NULL && cstate->data_dest_cb == NULL);
+	bool		pipe = (cstate->filename == NULL && cstate->data_dest_cb == NULL && !cstate->is_blackhole);
 	bool		fe_copy = (pipe && whereToSendOutput == DestRemote);
 	TupleDesc	tupDesc;
 	int			num_phys_attrs;
@@ -1249,6 +1260,15 @@ DoCopyTo(CopyToState cstate)
 
 	if (fe_copy)
 		SendCopyBegin(cstate);
+	else if (cstate->is_blackhole && whereToSendOutput == DestRemote)
+	{
+		/*
+		 * If we are in a blackhole copy from the frontend, we don't send a
+		 * CopyBegin message, but we still need to make sure psql or other
+		 * clients don't hang waiting for it. If we don't send CopyBegin,
+		 * client will just see the CommandComplete message later.
+		 */
+	}
 
 	if (cstate->rel)
 		tupDesc = RelationGetDescr(cstate->rel);
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ff4e1388c55..faaa92b5742 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -747,7 +747,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 	AGGREGATE ALL ALSO ALTER ALWAYS ANALYSE ANALYZE AND ANY ARRAY AS ASC
 	ASENSITIVE ASSERTION ASSIGNMENT ASYMMETRIC ATOMIC AT ATTACH ATTRIBUTE AUTHORIZATION
 
-	BACKWARD BEFORE BEGIN_P BETWEEN BIGINT BINARY BIT
+	BACKWARD BEFORE BEGIN_P BETWEEN BIGINT BINARY BIT BLACKHOLE
 	BOOLEAN_P BOTH BREADTH BY
 
 	CACHE CALL CALLED CASCADE CASCADED CASE CAST CATALOG_P CHAIN CHAR_P
@@ -3558,7 +3558,16 @@ CopyStmt:	COPY opt_binary qualified_name opt_column_list
 					n->attlist = $4;
 					n->is_from = $5;
 					n->is_program = $6;
-					n->filename = $7;
+					if ($7 == (char *) -1)
+					{
+						n->filename = NULL;
+						n->is_blackhole = true;
+					}
+					else
+					{
+						n->filename = $7;
+						n->is_blackhole = false;
+					}
 					n->whereClause = $11;
 
 					if (n->is_program && n->filename == NULL)
@@ -3593,7 +3602,16 @@ CopyStmt:	COPY opt_binary qualified_name opt_column_list
 					n->attlist = NIL;
 					n->is_from = false;
 					n->is_program = $6;
-					n->filename = $7;
+					if ($7 == (char *) -1)
+					{
+						n->filename = NULL;
+						n->is_blackhole = true;
+					}
+					else
+					{
+						n->filename = $7;
+						n->is_blackhole = false;
+					}
 					n->options = $9;
 
 					if (n->is_program && n->filename == NULL)
@@ -3625,6 +3643,7 @@ copy_file_name:
 			Sconst									{ $$ = $1; }
 			| STDIN									{ $$ = NULL; }
 			| STDOUT								{ $$ = NULL; }
+			| BLACKHOLE								{ $$ = (char *) -1; }
 		;
 
 copy_options: copy_opt_list							{ $$ = $1; }
@@ -18843,6 +18862,7 @@ unreserved_keyword:
 			| BACKWARD
 			| BEFORE
 			| BEGIN_P
+			| BLACKHOLE
 			| BREADTH
 			| BY
 			| CACHE
@@ -19413,6 +19433,7 @@ bare_label_keyword:
 			| BIGINT
 			| BINARY
 			| BIT
+			| BLACKHOLE
 			| BOOLEAN_P
 			| BOTH
 			| BREADTH
diff --git a/src/include/commands/copy.h b/src/include/commands/copy.h
index abecfe51098..4ecb387a929 100644
--- a/src/include/commands/copy.h
+++ b/src/include/commands/copy.h
@@ -131,6 +131,7 @@ extern DestReceiver *CreateCopyDestReceiver(void);
  */
 extern CopyToState BeginCopyTo(ParseState *pstate, Relation rel, RawStmt *raw_query,
 							   Oid queryRelId, const char *filename, bool is_program,
+							   bool is_blackhole,
 							   copy_data_dest_cb data_dest_cb, List *attnamelist, List *options);
 extern void EndCopyTo(CopyToState cstate);
 extern uint64 DoCopyTo(CopyToState cstate);
diff --git a/src/include/commands/progress.h b/src/include/commands/progress.h
index 2a12920c75f..dce5a87e84b 100644
--- a/src/include/commands/progress.h
+++ b/src/include/commands/progress.h
@@ -187,6 +187,7 @@
 #define PROGRESS_COPY_TYPE_PROGRAM 2
 #define PROGRESS_COPY_TYPE_PIPE 3
 #define PROGRESS_COPY_TYPE_CALLBACK 4
+#define PROGRESS_COPY_TYPE_BLACKHOLE 5
 
 /* Progress parameters for PROGRESS_DATACHECKSUMS */
 #define PROGRESS_DATACHECKSUMS_PHASE		0
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 91377a6cde3..f9adc99a4dd 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2786,6 +2786,7 @@ typedef struct CopyStmt
 								 * for all columns */
 	bool		is_from;		/* TO or FROM */
 	bool		is_program;		/* is 'filename' a program to popen? */
+	bool		is_blackhole;	/* is 'filename' BLACKHOLE? */
 	char	   *filename;		/* filename, or NULL for STDIN/STDOUT */
 	List	   *options;		/* List of DefElem nodes */
 	Node	   *whereClause;	/* WHERE condition (or NULL) */
diff --git a/src/include/parser/kwlist.h b/src/include/parser/kwlist.h
index 51ead54f015..e569def836d 100644
--- a/src/include/parser/kwlist.h
+++ b/src/include/parser/kwlist.h
@@ -61,6 +61,7 @@ PG_KEYWORD("between", BETWEEN, COL_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("bigint", BIGINT, COL_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("binary", BINARY, TYPE_FUNC_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("bit", BIT, COL_NAME_KEYWORD, BARE_LABEL)
+PG_KEYWORD("blackhole", BLACKHOLE, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("boolean", BOOLEAN_P, COL_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("both", BOTH, RESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("breadth", BREADTH, UNRESERVED_KEYWORD, BARE_LABEL)
diff --git a/src/test/modules/test_copy_callbacks/test_copy_callbacks.c b/src/test/modules/test_copy_callbacks/test_copy_callbacks.c
index f6b113e3e98..e79b94f6cb8 100644
--- a/src/test/modules/test_copy_callbacks/test_copy_callbacks.c
+++ b/src/test/modules/test_copy_callbacks/test_copy_callbacks.c
@@ -37,7 +37,7 @@ test_copy_to_callback(PG_FUNCTION_ARGS)
 	CopyToState cstate;
 	int64		processed;
 
-	cstate = BeginCopyTo(NULL, rel, NULL, RelationGetRelid(rel), NULL, false,
+	cstate = BeginCopyTo(NULL, rel, NULL, RelationGetRelid(rel), NULL, false, false,
 						 to_cb, NIL, NIL);
 	processed = DoCopyTo(cstate);
 	EndCopyTo(cstate);
diff --git a/src/test/regress/expected/copy.out b/src/test/regress/expected/copy.out
index 37498cdd6e7..0a44205bf88 100644
--- a/src/test/regress/expected/copy.out
+++ b/src/test/regress/expected/copy.out
@@ -605,3 +605,12 @@ id	val
 1	11
 2	12
 DROP TABLE pp_dropcol;
+-- Test COPY TO BLACKHOLE
+CREATE TABLE copy_blackhole_test (a int, b text);
+INSERT INTO copy_blackhole_test SELECT g, 'text' || g FROM generate_series(1, 10) g;
+COPY copy_blackhole_test TO BLACKHOLE;
+COPY (SELECT * FROM copy_blackhole_test) TO BLACKHOLE;
+-- COPY FROM BLACKHOLE should fail
+COPY copy_blackhole_test FROM BLACKHOLE;
+ERROR:  COPY FROM BLACKHOLE is not supported
+DROP TABLE copy_blackhole_test;
diff --git a/src/test/regress/sql/copy.sql b/src/test/regress/sql/copy.sql
index 094fd76c12b..16edb97db73 100644
--- a/src/test/regress/sql/copy.sql
+++ b/src/test/regress/sql/copy.sql
@@ -544,3 +544,12 @@ ALTER TABLE pp_dropcol ATTACH PARTITION pp_dropcol_1 FOR VALUES FROM (1) TO (10)
 INSERT INTO pp_dropcol VALUES (1, 11), (2, 12);
 COPY pp_dropcol TO stdout(header);
 DROP TABLE pp_dropcol;
+
+-- Test COPY TO BLACKHOLE
+CREATE TABLE copy_blackhole_test (a int, b text);
+INSERT INTO copy_blackhole_test SELECT g, 'text' || g FROM generate_series(1, 10) g;
+COPY copy_blackhole_test TO BLACKHOLE;
+COPY (SELECT * FROM copy_blackhole_test) TO BLACKHOLE;
+-- COPY FROM BLACKHOLE should fail
+COPY copy_blackhole_test FROM BLACKHOLE;
+DROP TABLE copy_blackhole_test;
-- 
2.43.0

