Skip to content

fix(orca): merge colid->attno mapping in CStatistics::AppendStats to avoid extended-stats crash - #1966

Open
yjhjstz wants to merge 3 commits into
apache:mainfrom
yjhjstz:fix/orca-extstats-appendstats-attno
Open

yjhjstz wants to merge 3 commits into
apache:mainfrom
yjhjstz:fix/orca-extstats-appendstats-attno

Conversation

@yjhjstz

@yjhjstz yjhjstz commented Sep 7, 2026

Copy link
Copy Markdown
Member

When a memo group's cached statistics lack columns needed by a later derivation request (e.g. a ROLLUP expanded into a CTE whose consumers request different column sets), the missing columns are derived separately and merged via CStatistics::AppendStats(). That merge copied histograms and widths only, so the colid -> attno mapping (added for extended statistics) diverged from the histogram map: a column could have a histogram but no attno entry.

ApplyCorrelatedStatsToScaleFactorFilterCalculation() then dereferenced the result of the attno lookup without a null check and crashed the coordinator with SIGSEGV whenever the table had any extended statistics object with stored data and a filter referenced one of the appended columns (e.g. TPC-DS Q67 after CREATE STATISTICS (ndistinct) on item/date_dim). Reproduced on main with a cassert build: colid 11 had a histogram (5 entries) but the attno map only held 4 entries, attnum was NULL at CExtendedStatsProcessor.cpp:280. This is the same class of bug as the CTE remapping fix in 124a048e8c6 (gpdb#16212), which repaired CopyStatsWithRemap() but not AppendStats().

Fix both layers:

  • CStatistics::AppendStats() now also merges the colid -> attno mapping (copy-on-write, since the mapping is shared by refcount across stats objects, see ScaleStats()) and adopts the input's extended-stats info when none is set, restoring the invariant that every column with a histogram has an attno entry.

  • CExtendedStatsProcessor now null-checks the attno and histogram lookups and skips clauses it cannot translate, falling back to the independence assumption, matching what ApplyCorrelatedStatsToNDistinctCalculation() already does. Also bail out gracefully if no ndistinct item matches the attribute set.

  • CExtendedStatsProcessor also skips system columns (attno <= 0): extended statistics never cover them, and feeding a negative attno into CBitSet::ExchangeSet() would convert it to a huge unsigned bit index. The backend rejects these clauses too (dependency_is_compatible_clause()).

Add a regression test with the minimized reproduction: a ROLLUP over a join where the dimension table has ndistinct extended statistics and the filter column is appended to the scan group's stats after the initial derivation. Verified the gporca test passes with both optimizer=on and optimizer=off answer files.

Ported from warehouse-pg/warehouse-pg#251.

Fixes #1949

What does this PR do?

Type of Change

  • Bug fix (non-breaking change)
  • New feature (non-breaking change)
  • Breaking change (fix or feature with breaking changes)
  • Documentation update

Breaking Changes

Test Plan

  • Unit tests added/updated
  • Integration tests added/updated
  • Passed make installcheck
  • Passed make -C src/test installcheck-cbdb-parallel

Impact

Performance:

User-facing changes:

Dependencies:

Checklist

Additional Context

CI Skip Instructions


@yjhjstz
yjhjstz marked this pull request as draft September 7, 2026 01:41
@yjhjstz
yjhjstz marked this pull request as ready for review September 7, 2026 16:34
yjhjstz and others added 2 commits September 28, 2026 23:53
…avoid extended-stats crash

When a memo group's cached statistics lack columns needed by a later
derivation request (e.g. a ROLLUP expanded into a CTE whose consumers
request different column sets), the missing columns are derived
separately and merged via CStatistics::AppendStats(). That merge copied
histograms and widths only, so the colid -> attno mapping (added for
extended statistics) diverged from the histogram map: a column could
have a histogram but no attno entry.

ApplyCorrelatedStatsToScaleFactorFilterCalculation() then dereferenced
the result of the attno lookup without a null check and crashed the
coordinator with SIGSEGV whenever the table had any extended statistics
object with stored data and a filter referenced one of the appended
columns (e.g. TPC-DS Q67 after CREATE STATISTICS (ndistinct) on
item/date_dim). Reproduced on main with a cassert build: colid 11 had a
histogram (5 entries) but the attno map only held 4 entries, attnum was
NULL at CExtendedStatsProcessor.cpp:280. This is the same class of bug
as the CTE remapping fix in 124a048e8c6 (gpdb#16212), which repaired
CopyStatsWithRemap() but not AppendStats().

Fix both layers:

* CStatistics::AppendStats() now also merges the colid -> attno mapping
  (copy-on-write, since the mapping is shared by refcount across stats
  objects, see ScaleStats()) and adopts the input's extended-stats info
  when none is set, restoring the invariant that every column with a
  histogram has an attno entry.

* CExtendedStatsProcessor now null-checks the attno and histogram
  lookups and skips clauses it cannot translate, falling back to the
  independence assumption, matching what
  ApplyCorrelatedStatsToNDistinctCalculation() already does. Also bail
  out gracefully if no ndistinct item matches the attribute set.

* CExtendedStatsProcessor also skips system columns (attno <= 0):
  extended statistics never cover them, and feeding a negative attno
  into CBitSet::ExchangeSet() would convert it to a huge unsigned bit
  index. The backend rejects these clauses too
  (dependency_is_compatible_clause()).

Add a regression test with the minimized reproduction: a ROLLUP over a
join where the dimension table has ndistinct extended statistics and
the filter column is appended to the scan group's stats after the
initial derivation. Verified the gporca test passes with both
optimizer=on and optimizer=off answer files.

Ported from warehouse-pg/warehouse-pg#251.

Co-authored-by: Junfeng Yang <junfengyang@link.cuhk.edu.hk>
Follow-up to the null-histogram fallback in MakeHistHashMapConjFilter():
apply the same treatment in MakeHistHashMapDisjFilter() so that a filter
column without a histogram, e.g. in WHERE (c2 > 0 OR c1 = 'x'), is
estimated with the default selectivity instead of dereferencing null.

* A simple predicate whose column has no histogram is skipped before the
  per-column bookkeeping, like an unsupported predicate, so the previous
  column's intermediate result is not flushed twice.
* A nested conjunction/disjunction on a single column whose histogram is
  missing from the child result falls back to the multi-column merge
  path instead of copying a null histogram.
* The conjunction path only replaces the column histogram after a
  single-column disjunction when the disjunction produced one.

Addresses review feedback on apache#1966.
@yjhjstz
yjhjstz force-pushed the fix/orca-extstats-appendstats-attno branch from f511d13 to 6574841 Compare September 28, 2026 16:15
@yjhjstz
yjhjstz requested a review from leborchuk September 29, 2026 02:08

@leborchuk leborchuk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@leborchuk leborchuk added the type: Orca only orca has the issue label Oct 2, 2026
@Alena0704 Alena0704 added the type: Bug Something isn't working label Oct 4, 2026
@Alena0704
Alena0704 self-requested a review October 21, 2026 12:10
// the column has no histogram, so the child left none
// behind; treat the child like a multi-column predicate
// and only merge its histogram map and scale factor
is_colid_present = false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like colid = gpos::ulong_max; is missing here.
Without this reset, the next nested branch on the same column overwrites the previous branch’s scale factor instead of accumulating it, making OR estimates depend on branch order. Resetting colid preserves both branches’ contributions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and it turns out the consequence is worse than just the scale factor.

The multi-column path at the bottom of the loop is otherwise only reached with colid == gpos::ulong_max, which makes IsNewStatsColumn() true at the top of the loop, so that path assumes the pending per-column state has already been flushed. By falling into it with a real colid still set, my patch broke that assumption twice:

  1. as you say, a later disjunct on the same column sees previous_colid == colid, so previous_scale_factor is never appended to scale_factors and is overwritten instead of accumulated — the OR estimate becomes branch-order dependent;
  2. the path's previous_histogram = nullptr; (a no-op in the original code, since the flush had already run) now drops a live CHistogram * — its contribution to the disjunction is lost and the object is leaked.

Fixed in a1af361 by flushing the pending histogram and scale factor in the fallback and then resetting colid:

if (nullptr != previous_histogram)
{
    scale_factors->Append(
        GPOS_NEW(mp) CDouble(previous_scale_factor.Get()));
    CStatisticsUtils::UpdateDisjStatistics(
        mp, non_updatable_cols, input_rows, cumulative_rows,
        previous_histogram, disjunctive_result_histograms,
        previous_colid);
    previous_histogram = nullptr;
}
is_colid_present = false;
colid = gpos::ulong_max;

The nullptr != previous_histogram guard is what distinguishes "state pending for this column" from "already flushed at the top of this iteration" — in the latter case previous_scale_factor still holds the value that was just appended, so an unconditional append would double-count it.

Verified on a cassert build (ORCA on): gporca (which carries this PR's new test), stats_ext, bfv_statistic, table_statistics, gporca_faults, qp_orca_fallback, bfv_joins, bfv_subquery, bfv_partition_plans, subselect_gp, qp_select, select, subselect, orca_static_pruning all pass.

… histogram

The null-histogram fallback added for MakeHistHashMapDisjFilter() routes
the child to the multi-column merge path but kept the real column id.
That path is otherwise only reached with colid == gpos::ulong_max, which
makes IsNewStatsColumn() true at the top of the loop, so it assumes the
pending per-column state has already been flushed. With a real column id
left in place:

* a later disjunct on the same column found previous_colid == colid, so
  the pending scale factor was never appended to scale_factors and was
  overwritten instead of accumulated, making the OR estimate depend on
  branch order;
* the pending previous_histogram was dropped on the floor by the
  path's 'previous_histogram = nullptr', losing its contribution to the
  disjunction and leaking it.

Flush the pending histogram and scale factor in the fallback, then reset
colid to gpos::ulong_max so the invariant the multi-column path relies on
holds again.

Reported-by: Alena Rybakina
// merging is only valid for stats objects describing the same relation;
// all callers merge stats of the same memo group / expression today.
GPOS_ASSERT(nullptr == m_ext_stats || nullptr == stats->m_ext_stats ||
m_ext_stats == stats->m_ext_stats);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would MDId()->Equals() be better?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: Bug Something isn't working type: Orca only orca has the issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] ORCA: QD segfault in CExtendedStatsProcessor when extended statistics (dependencies) do not cover all filtered columns

3 participants