From c2b72f0563aafd83868ba8983f8a42da6c79529b Mon Sep 17 00:00:00 2001 From: Ewan Young Date: Tue, 7 Jul 2026 23:40:30 +0800 Subject: [PATCH] Reject ill-formed range bounds histograms in pg_restore_attribute_stats() pg_restore_attribute_stats() (and pg_set_attribute_stats()) stored a STATISTIC_KIND_BOUNDS_HISTOGRAM verbatim, without checking the invariant that ANALYZE's compute_range_stats() guarantees: the histogram must not contain any empty range, and its lower and upper bounds must each appear in ascending order. The range and multirange selectivity estimators rely on this. An unsorted histogram makes calc_length_hist_frac() fail an assertion (or compute a nonsensical, possibly negative, selectivity on a non-assert build), and an empty range trips a "shouldn't happen" elog(ERROR), when the planner later reads the imported statistics. For instance, after importing an unsorted bounds histogram, a query such as "WHERE r <@ ..." crashes the backend during planning. Validate the bounds histogram at import time and reject an ill-formed one with a WARNING, like other inconsistent inputs. This covers both range- and multirange-typed columns, whose bounds histograms are both arrays of ranges. This is the same class of problem as commit f6e4ec0a705, which rejected oversized MCV lists in pg_restore_extended_stats(). --- src/backend/statistics/attribute_stats.c | 89 +++++++++++++++++++++- src/test/regress/expected/stats_import.out | 28 +++++++ src/test/regress/sql/stats_import.sql | 18 +++++ 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/src/backend/statistics/attribute_stats.c b/src/backend/statistics/attribute_stats.c index 1cc4d657231..9138434ba03 100644 --- a/src/backend/statistics/attribute_stats.c +++ b/src/backend/statistics/attribute_stats.c @@ -28,7 +28,9 @@ #include "utils/builtins.h" #include "utils/fmgroids.h" #include "utils/lsyscache.h" +#include "utils/rangetypes.h" #include "utils/syscache.h" +#include "utils/typcache.h" /* * Positional argument numbers, names, and types for @@ -105,10 +107,94 @@ static struct StatsArgInfo cleararginfo[] = }; static bool attribute_statistics_update(FunctionCallInfo fcinfo); +static bool statatt_check_bounds_histogram(Datum arrayval); static void upsert_pg_statistic(Relation starel, HeapTuple oldtup, const Datum *values, const bool *nulls, const bool *replaces); static bool delete_pg_statistic(Oid reloid, AttrNumber attnum, bool stainherit); +/* + * Check that an imported bounds histogram (STATISTIC_KIND_BOUNDS_HISTOGRAM) is + * well-formed the way ANALYZE builds it in compute_range_stats(): it must not + * contain any empty range, and both its lower and its upper bounds must appear + * in non-decreasing order. The range and multirange selectivity estimators + * rely on this: they split the histogram into separate lower- and upper-bound + * histograms and assume each is sorted (e.g. calc_length_hist_frac() Asserts + * that successive bound distances do not decrease, and the estimator throws an + * internal error if it finds an empty range). Importing an unsorted or + * empty-containing histogram would therefore crash the backend, or produce + * nonsensical estimates on non-assert builds, when the histogram is later read + * by the planner. Reject such input with a WARNING, like other inconsistent + * inputs. + * + * For both range- and multirange-typed columns the histogram is an array of + * ranges, so we take the range type from the array's element type. + */ +static bool +statatt_check_bounds_histogram(Datum arrayval) +{ + ArrayType *arr = DatumGetArrayTypeP(arrayval); + Oid rngtypid = ARR_ELEMTYPE(arr); + TypeCacheEntry *typcache; + int16 elmlen; + bool elmbyval; + char elmalign; + Datum *elems; + bool *nulls; + int nelems; + RangeBound prev_lower = {0}; + RangeBound prev_upper = {0}; + + typcache = lookup_type_cache(rngtypid, TYPECACHE_RANGE_INFO); + + /* + * The element type should always be a range type here, but be defensive: + * if it isn't, the bounds histogram is never consulted by the range + * estimator, so there is nothing to verify. + */ + if (typcache->rngelemtype == NULL) + return true; + + get_typlenbyvalalign(rngtypid, &elmlen, &elmbyval, &elmalign); + deconstruct_array(arr, rngtypid, elmlen, elmbyval, elmalign, + &elems, &nulls, &nelems); + + for (int i = 0; i < nelems; i++) + { + RangeBound lower, + upper; + bool empty; + + /* NULL elements are already rejected by statatt_build_stavalues() */ + range_deserialize(typcache, DatumGetRangeTypeP(elems[i]), + &lower, &upper, &empty); + + if (empty) + { + ereport(WARNING, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("\"%s\" must not contain empty ranges", + "range_bounds_histogram"))); + return false; + } + + if (i > 0 && + (range_cmp_bounds(typcache, &lower, &prev_lower) < 0 || + range_cmp_bounds(typcache, &upper, &prev_upper) < 0)) + { + ereport(WARNING, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("\"%s\" must have its lower and upper bounds sorted in ascending order", + "range_bounds_histogram"))); + return false; + } + + prev_lower = lower; + prev_upper = upper; + } + + return true; +} + /* * Insert or Update Attribute Statistics * @@ -488,7 +574,8 @@ attribute_statistics_update(FunctionCallInfo fcinfo) atttypid, atttypmod, &converted); - if (converted) + if (converted && + statatt_check_bounds_histogram(stavalues)) { statatt_set_slot(values, nulls, replaces, STATISTIC_KIND_BOUNDS_HISTOGRAM, diff --git a/src/test/regress/expected/stats_import.out b/src/test/regress/expected/stats_import.out index bfdff77afa7..93c190f7194 100644 --- a/src/test/regress/expected/stats_import.out +++ b/src/test/regress/expected/stats_import.out @@ -1187,6 +1187,34 @@ AND attname = 'arange'; stats_import | test | arange | f | 0.29 | 0 | 0 | | | | | | | | {399,499,Infinity} | 0.5 | {"[-1,1)","[0,4)","[1,4)","[1,100)"} (1 row) +-- warn: range bounds histogram whose bounds are not sorted ascending +SELECT pg_catalog.pg_restore_attribute_stats( + 'schemaname', 'stats_import', + 'relname', 'test', + 'attname', 'arange', + 'inherited', false::boolean, + 'range_bounds_histogram', '{"[50,60)","[1,2)","[90,100)","[5,6)"}'::text + ); +WARNING: "range_bounds_histogram" must have its lower and upper bounds sorted in ascending order + pg_restore_attribute_stats +---------------------------- + f +(1 row) + +-- warn: range bounds histogram containing an empty range +SELECT pg_catalog.pg_restore_attribute_stats( + 'schemaname', 'stats_import', + 'relname', 'test', + 'attname', 'arange', + 'inherited', false::boolean, + 'range_bounds_histogram', '{empty,"[1,2)","[3,4)"}'::text + ); +WARNING: "range_bounds_histogram" must not contain empty ranges + pg_restore_attribute_stats +---------------------------- + f +(1 row) + -- warn: cannot set most_common_elems for range type, rest ok SELECT pg_catalog.pg_restore_attribute_stats( 'schemaname', 'stats_import', diff --git a/src/test/regress/sql/stats_import.sql b/src/test/regress/sql/stats_import.sql index 58140315efb..31ac64f41b9 100644 --- a/src/test/regress/sql/stats_import.sql +++ b/src/test/regress/sql/stats_import.sql @@ -885,6 +885,24 @@ AND tablename = 'test' AND inherited = false AND attname = 'arange'; +-- warn: range bounds histogram whose bounds are not sorted ascending +SELECT pg_catalog.pg_restore_attribute_stats( + 'schemaname', 'stats_import', + 'relname', 'test', + 'attname', 'arange', + 'inherited', false::boolean, + 'range_bounds_histogram', '{"[50,60)","[1,2)","[90,100)","[5,6)"}'::text + ); + +-- warn: range bounds histogram containing an empty range +SELECT pg_catalog.pg_restore_attribute_stats( + 'schemaname', 'stats_import', + 'relname', 'test', + 'attname', 'arange', + 'inherited', false::boolean, + 'range_bounds_histogram', '{empty,"[1,2)","[3,4)"}'::text + ); + -- warn: cannot set most_common_elems for range type, rest ok SELECT pg_catalog.pg_restore_attribute_stats( 'schemaname', 'stats_import', -- 2.47.3