Skip to content

compareStats is non-transitive (0.01 selectivity tolerance); blocks join_order sort.Slice→slices migration #25702

Description

@fengttt

Summary

compareStats in pkg/sql/plan/stats.go is not a transitive (strict weak) ordering, so it cannot be used as a slices.SortFunc comparator and blocks migrating its two call sites in pkg/sql/plan/join_order.go off sort.Slice.

func compareStats(stats1, stats2 *Stats) bool {
	if math.Abs(stats1.Selectivity-stats2.Selectivity) > 0.01 {
		return stats1.Selectivity < stats2.Selectivity
	}
	return stats1.Outcnt < stats2.Outcnt
}

The 0.01 selectivity tolerance makes it intransitive. Counter-example (from review):
A=(sel=0.000, outcnt=3), B=(sel=0.009, outcnt=2), C=(sel=0.018, outcnt=1):

  • B < A — |0.009-0.000| ≤ 0.01 → compare outcnt: 2 < 3 ✓
  • C < B — |0.018-0.009| ≤ 0.01 → compare outcnt: 1 < 2 ✓
  • A < C — |0.000-0.018| > 0.01 → compare selectivity: 0.000 < 0.018 ✓

So B < A < C < B — a cycle. sort.Slice tolerates this (produces some order); slices.SortFunc requires a strict weak ordering and its result is undefined for a comparator like this.

Impact / status

Proposed fix

Refactor compareStats into a genuinely transitive ordering key, e.g. bucket the selectivity onto a fixed 0.01 grid and sort lexicographically by (bucket, outcnt), so equal-bucket selectivities compare by outcnt and the relation is transitive. Note this changes tie behavior at bucket edges, so it may shift some join-order plans and require EXPLAIN golden updates — hence it is tracked separately from the mechanical sort→slices migration.

Once transitive, migrate the two join_order.go sites to slices.SortFunc.

Related: #25646 (the same class of non-strict/undefined comparator in the filter-cost sort).

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions