From 26864c5e9946fb975f54295d65095b5ead231169 Mon Sep 17 00:00:00 2001
From: shihao zhong <zhong950419@gmail.com>
Date: Sun, 18 Oct 2026 21:12:15 -0400
Subject: [PATCH v1] Emit null-keyed outer hash join tuples immediately

Commit 1811f1af98f put outer tuples with null join keys into a
tuplestore and emitted them at the end of the batch, which spills to
disk past work_mem/16.  Let them probe with a dummy hash value instead,
so they are emitted right away.  Multi-batch parallel joins still use
the tuplestore.
---
 src/backend/executor/nodeHashjoin.c | 20 ++++++++++++--------
 src/include/executor/hashjoin.h     |  8 +++++---
 src/include/nodes/execnodes.h       |  3 ++-
 3 files changed, 19 insertions(+), 12 deletions(-)

diff --git a/src/backend/executor/nodeHashjoin.c b/src/backend/executor/nodeHashjoin.c
index 202dd866251..c49a8d87422 100644
--- a/src/backend/executor/nodeHashjoin.c
+++ b/src/backend/executor/nodeHashjoin.c
@@ -1154,10 +1154,12 @@ ExecHashJoinOuterGetTuple(PlanState *outerNode,
 			}
 			else if (hjstate->hj_KeepNullTuples)
 			{
-				/* null join key, but we must save tuple to be emitted later */
-				if (hjstate->hj_NullOuterTupleStore == NULL)
-					hjstate->hj_NullOuterTupleStore = ExecHashBuildNullTupleStore(hashtable);
-				tuplestore_puttupleslot(hjstate->hj_NullOuterTupleStore, slot);
+				/*
+				 * null join key; probe with a dummy hash value, which goes to
+				 * the current batch and cannot match
+				 */
+				*hashvalue = 0;
+				return slot;
 			}
 
 			/*
@@ -1232,10 +1234,12 @@ ExecParallelHashJoinOuterGetTuple(PlanState *outerNode,
 			}
 			else if (hjstate->hj_KeepNullTuples)
 			{
-				/* null join key, but we must save tuple to be emitted later */
-				if (hjstate->hj_NullOuterTupleStore == NULL)
-					hjstate->hj_NullOuterTupleStore = ExecHashBuildNullTupleStore(hashtable);
-				tuplestore_puttupleslot(hjstate->hj_NullOuterTupleStore, slot);
+				/*
+				 * null join key; probe with a dummy hash value, which goes to
+				 * the current batch and cannot match
+				 */
+				*hashvalue = 0;
+				return slot;
 			}
 
 			/*
diff --git a/src/include/executor/hashjoin.h b/src/include/executor/hashjoin.h
index 4d342174b9a..f15691b4569 100644
--- a/src/include/executor/hashjoin.h
+++ b/src/include/executor/hashjoin.h
@@ -74,9 +74,11 @@
  * If an input tuple has a null join key, then it cannot match anything from
  * the other side of the join.  Normally we can just discard such a tuple
  * immediately, but if it comes from the outer side of an outer join then we
- * must emit it with null-extension of the other side.  For various reasons
- * it's not convenient to do that immediately on seeing the tuple, so we dump
- * the tuple into a tuplestore and emit it later.  (In the unlikely but
+ * must emit it with null-extension of the other side.  An outer tuple just
+ * probes the hash table with a dummy hash value, finds no match and is
+ * emitted.  For an inner tuple, or an outer tuple of a multi-batch parallel
+ * join, that is not convenient, so we dump the tuple into a tuplestore and
+ * emit it later.  (In the unlikely but
  * supported case of a non-strict join operator, we treat null keys as normal
  * data.)
  * ----------------------------------------------------------------
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index e3cea52e7df..7fbccaea519 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -2203,7 +2203,8 @@ typedef struct MergeJoinState
  *								outer joins
  *		hj_NullInnerTupleSlot	prepared null tuple for left/full outer joins
  *		hj_NullOuterTupleStore	tuplestore holding outer tuples that have
- *								null join keys (but must be emitted anyway)
+ *								null join keys (but must be emitted anyway),
+ *								used only in multi-batch parallel joins
  *		hj_FirstOuterTupleSlot	first tuple retrieved from outer plan
  *		hj_JoinState			current state of ExecHashJoin state machine
  *		hj_KeepNullTuples		true to keep outer tuples with null join keys
-- 
2.37.1 (Apple Git-137.1)

