[PATCH] Avoid collation lookup for "char" statistics
From e9acd79085a88981f800e982f65e373167828359 Mon Sep 17 00:00:00 2001
From: Feng Wu <wufengwufengwufeng@gmail.com>
Date: Sat, 27 Jun 2026 10:06:37 +0800
Subject: [PATCH] Avoid collation lookup for "char" statistics
mergejoinscansel() can estimate merge join scan fractions from histogram
statistics via scalarineqsel(). When the join key has the internal
"char" type, convert_to_scalar() treats the histogram values as
string-like values. convert_string_datum() then attempted to look up
the input collation.
The internal "char" type is not collatable, and its btree operators
order the byte value directly. Return the one-byte string for CHAROID
without consulting collation state, avoiding a lookup of InvalidOid as
collation 0.
Add a regression test that exercises merge join selectivity estimation
with histogram statistics on "char" columns.
---
src/backend/utils/adt/selfuncs.c | 7 ++++++
src/test/regress/expected/join.out | 38 ++++++++++++++++++++++++++++++
src/test/regress/sql/join.sql | 21 +++++++++++++++++
3 files changed, 66 insertions(+)
diff --git a/src/backend/utils/adt/selfuncs.c b/src/backend/utils/adt/selfuncs.c
index d6efd070..7318dda8 100644
--- a/src/backend/utils/adt/selfuncs.c
+++ b/src/backend/utils/adt/selfuncs.c
@@ -5295,6 +5295,13 @@ convert_string_datum(Datum value, Oid typid,
Oid collid, bool *failure)
return NULL;
}
+ /*
+ * The internal "char" type is not collatable, so the byte value above is
+ * already in the comparison order used by its btree operators.
+ */
+ if (typid == CHAROID)
+ return val;
+
mylocale = pg_newlocale_from_collation(collid);
if (!mylocale->collate_is_c)
diff --git a/src/test/regress/expected/join.out
b/src/test/regress/expected/join.out
index ed946abe..98355dd0 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -10165,3 +10165,41 @@ SELECT COUNT(*) FROM onek t1 LEFT JOIN tenk1 t2
19000
(1 row)
+-- Test merge join selectivity estimation with non-collatable "char" stats.
+BEGIN;
+CREATE TEMP TABLE char_stats_1 (c "char");
+CREATE TEMP TABLE char_stats_2 (c "char");
+INSERT INTO char_stats_1
+SELECT v::"char"
+FROM unnest(array['I','S','c','i','m','p','r','t','v']) AS v,
+ generate_series(1, CASE WHEN v IN ('i','v','r','t') THEN 50 ELSE 1 END);
+INSERT INTO char_stats_2
+SELECT v::"char"
+FROM unnest(array['a','e','i']) AS v,
+ generate_series(1, CASE WHEN v = 'i' THEN 50 ELSE 5 END);
+ANALYZE char_stats_1;
+ANALYZE char_stats_2;
+SET LOCAL enable_hashjoin = off;
+SET LOCAL enable_nestloop = off;
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM char_stats_1 s1 JOIN char_stats_2 s2 ON s1.c = s2.c;
+ QUERY PLAN
+-----------------------------------------------
+ Aggregate
+ -> Merge Join
+ Merge Cond: (s2.c = s1.c)
+ -> Sort
+ Sort Key: s2.c
+ -> Seq Scan on char_stats_2 s2
+ -> Sort
+ Sort Key: s1.c
+ -> Seq Scan on char_stats_1 s1
+(9 rows)
+
+SELECT count(*) FROM char_stats_1 s1 JOIN char_stats_2 s2 ON s1.c = s2.c;
+ count
+-------
+ 2500
+(1 row)
+
+ROLLBACK;
diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql
index 78f7b4f5..c405480b 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -3903,3 +3903,24 @@ SELECT COUNT(*) FROM onek t1 LEFT JOIN tenk1 t2
ON (t2.thousand = t1.tenthous OR t2.thousand = t1.thousand);
SELECT COUNT(*) FROM onek t1 LEFT JOIN tenk1 t2
ON (t2.thousand = t1.tenthous OR t2.thousand = t1.thousand);
+
+-- Test merge join selectivity estimation with non-collatable "char" stats.
+BEGIN;
+CREATE TEMP TABLE char_stats_1 (c "char");
+CREATE TEMP TABLE char_stats_2 (c "char");
+INSERT INTO char_stats_1
+SELECT v::"char"
+FROM unnest(array['I','S','c','i','m','p','r','t','v']) AS v,
+ generate_series(1, CASE WHEN v IN ('i','v','r','t') THEN 50 ELSE 1 END);
+INSERT INTO char_stats_2
+SELECT v::"char"
+FROM unnest(array['a','e','i']) AS v,
+ generate_series(1, CASE WHEN v = 'i' THEN 50 ELSE 5 END);
+ANALYZE char_stats_1;
+ANALYZE char_stats_2;
+SET LOCAL enable_hashjoin = off;
+SET LOCAL enable_nestloop = off;
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM char_stats_1 s1 JOIN char_stats_2 s2 ON s1.c = s2.c;
+SELECT count(*) FROM char_stats_1 s1 JOIN char_stats_2 s2 ON s1.c = s2.c;
+ROLLBACK;
--
2.53.0
Feng Wu <wufengwufengwufeng@gmail.com> writes:
The internal "char" type is not collatable, and its btree operators
order the byte value directly. Return the one-byte string for CHAROID
without consulting collation state, avoiding a lookup of InvalidOid as
collation 0.
Can you demonstrate that there is a problem worth worrying about here?
We've had no field reports of failures in this code, and I don't see
a problem with, e.g.
explain select * from pg_type a join pg_type b using (typtype);
regards, tom lane
Hi Tom,
Thanks for looking at it. I should have included the exact reproducer in
the first email.
The failure needs histogram statistics on a "char" column, so simple
catalog joins such as the pg_type example do not necessarily reach the
problematic path. For example, I just rechecked that your query plans as
a hash join for me:
explain select * from pg_type a join pg_type b using (typtype);
To reach the failing path, this reproduces it for me on current master:
create temp table char_stats_1 (c "char");
create temp table char_stats_2 (c "char");
insert into char_stats_1
select v::"char"
from unnest(array['I','S','c','i','m','p','r','t','v']) as v,
generate_series(1,
case when v in ('i','v','r','t') then 50 else 1 end);
insert into char_stats_2
select v::"char"
from unnest(array['a','e','i']) as v,
generate_series(1, case when v = 'i' then 50 else 5 end);
analyze char_stats_1;
analyze char_stats_2;
set enable_hashjoin = off;
set enable_nestloop = off;
explain (costs off)
select count(*)
from char_stats_1 s1
join char_stats_2 s2 on s1.c = s2.c;
After ANALYZE, the first column has histogram statistics, e.g.:
tablename | attname | n_distinct | most_common_vals | histogram_bounds
-------------+---------+------------+------------------+------------------
char_stats_1 | c | 9 | {i,r,t,v} | {I,S,c,m,p}
char_stats_2 | c | 3 | {i,a,e} |
Without the patch, planning fails with:
ERROR: XX000: cache lookup failed for collation 0
LOCATION: pg_newlocale_from_collation, pg_locale.c:1211
The reason is that mergejoinscansel() calls scalarineqsel() while costing
the merge join. That can reach convert_to_scalar() for histogram bounds.
For CHAROID, convert_string_datum() builds a one-byte string, but then
still calls pg_newlocale_from_collation() with the clause input collation,
which is InvalidOid for the non-collatable "char" type.
The patch only skips that collation lookup for CHAROID. For text,
varchar, bpchar, and name, the existing locale handling is unchanged.
Regards,
Feng
Feng Wu <wufengwufengwufeng@gmail.com> writes:
Thanks for looking at it. I should have included the exact reproducer in
the first email.
The failure needs histogram statistics on a "char" column, so simple
catalog joins such as the pg_type example do not necessarily reach the
problematic path.
Ah, now I understand. The failing code is only reached if we have
a histogram on a "char" column. For almost all the catalogs with
"char" columns, there is no histogram because there are very few
distinct values and so the MCV list accounts for all of them.
Even where there is a histogram, it'd be unlikely for someone to
issue a query that would hit this code, so the lack of prior
reports isn't as surprising as I thought.
Your example could be simplified though. The failure is within
scalarineqsel, so we don't need a join at all, just an inequality
qual:
regression=# explain verbose select * from char_stats_1 where c < 'c';
ERROR: cache lookup failed for collation 0
I agree that just skipping the collation correction is the right
fix. We could make the patch a bit shorter by simply returning
out of the CHAROID case in the preceding switch. Will see to it.
regards, tom lane
I wrote:
Even where there is a histogram, it'd be unlikely for someone to
issue a query that would hit this code, so the lack of prior
reports isn't as surprising as I thought.
I had supposed that this was an old bug, but on attempting to
back-patch I found it wasn't broken before v18. So that's another
big reason for lack of prior reports. Commit 06421b084 replaced a
call to lc_collate_is_c(), which tolerated InvalidOid, with
pg_newlocale_from_collation() which doesn't.
Seeing this, I thought we'd be best off to put the test where
you had it but make it test for !OidIsValid(collid) rather than
hard-wiring typid == CHAROID. There probably aren't other cases
where we reach here with collid 0, but if there are, we want the
code to not fail, as it did not before.
Pushed with a test based on an inequality comparison (in HEAD
only, because there didn't seem to be a suitable test file in 18).
regards, tom lane