englefly commented on code in PR #67549:
URL: https://github.com/apache/doris/pull/67549#discussion_r4240221848


##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/HboStatsCalculator.java:
##########
@@ -27,17 +29,36 @@
 import org.apache.doris.nereids.trees.plans.algebra.Aggregate;
 import org.apache.doris.nereids.trees.plans.algebra.Filter;
 import org.apache.doris.nereids.trees.plans.algebra.Join;
-import org.apache.doris.statistics.hbo.PlanStatistics;
+import org.apache.doris.nereids.trees.plans.algebra.OlapScan;
 import org.apache.doris.statistics.hbo.RecentRunsPlanStatistics;
+import org.apache.doris.statistics.hbo.RecentRunsPlanStatisticsEntry;
 import org.apache.doris.statistics.model.Statistics;
 
+import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 
 /**
  * StatsCalculator by using hbo plan stats. to do estimation.
  */
 public class HboStatsCalculator extends StatsCalculator {
+    /** Pinned lookup order for filter roots: exact form first, then the 
constant agnostic form. */
+    private static final GroupStructInfo.LiteralMode[] FILTER_LOOKUP_MODES = {
+            GroupStructInfo.LiteralMode.WITH_LITERAL,
+            GroupStructInfo.LiteralMode.NO_LITERAL,
+    };
+
+    /**
+     * Lookup modes of a join / aggregation root. Their canonical form is 
never keyed with literals
+     * ({@code HBO SET STATISTICS} rejects a {@code J{...}} / {@code A{...}} 
struct in WITH_LITERAL
+     * mode), so trying that form would only cost a second struct-info 
computation per group without
+     * any chance to match.
+     */
+    private static final GroupStructInfo.LiteralMode[] JOIN_AGG_LOOKUP_MODES = 
{

Review Comment:
   认可这是设计缺陷:J/A 的 key 目前只有 NO_LITERAL 形式,filter 里的常量差异会撞到同一个 join/aggregate 
key。修复需要把 J/A 的 key 扩展为携带 literal 的形式(key 结构 + SET 校验一起改),本轮未做,计划在下一个 commit 处理。



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/GroupStructInfo.java:
##########
@@ -0,0 +1,905 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.nereids.stats;
+
+import org.apache.doris.nereids.memo.Group;
+import org.apache.doris.nereids.memo.GroupExpression;
+import org.apache.doris.nereids.trees.expressions.And;
+import org.apache.doris.nereids.trees.expressions.EqualTo;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.Or;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.expressions.literal.Literal;
+import org.apache.doris.nereids.trees.plans.AbstractPlan;
+import org.apache.doris.nereids.trees.plans.JoinType;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalAggregate;
+import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
+import org.apache.doris.nereids.trees.plans.logical.LogicalJoin;
+import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
+import org.apache.doris.nereids.util.MutableState;
+import org.apache.doris.qe.ConnectContext;
+
+import com.google.common.hash.Hashing;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+/**
+ * Simplified struct info of a memo {@link Group}, used by History Based 
Optimization (HBO).
+ *
+ * <p>The struct info is a property of the <b>group</b>, not of one of its 
group expressions: all
+ * expressions of a group are logically equivalent, so they describe the same 
set of relations with
+ * the same predicates, and the canonical form below is deliberately 
insensitive to which expression
+ * is picked.
+ * <ul>
+ *   <li>join commutativity: children of a join are canonicalized by 
sorting;</li>
+ *   <li>join associativity: a whole inner / cross join chain is emitted as 
one node
+ *       ({@code J{inner,c:[...]}(...;...)}) whose children are the chain 
leaves sorted by their
+ *       canonical form and whose conditions are the sorted union of every 
condition of the chain,
+ *       so {@code (A join B) join C} and {@code A join (B join C)} produce 
the same descriptor;</li>
+ *   <li>aggregation: only the group by keys and the child participate, 
because the output row count
+ *       of an aggregation is the number of groups and cannot be changed by 
the aggregate functions
+ *       or by the output expressions;</li>
+ *   <li>project is transparent for structure matching.</li>
+ * </ul>
+ * This mirrors what the MV oriented {@code rules.exploration.mv.StructInfo} 
does (it identifies a
+ * sub tree by its relation set and by level independent - shuttled - 
predicates), with every MV
+ * specific field dropped: no hyper graph, no shuttle map, no equivalence 
class, no lineage.
+ *
+ * <p>What the canonical form keeps:
+ * <ul>
+ *   <li>for each scan: the table qualifier and - as an annotation only - the 
data state of the scan
+ *       (its visible version, the rows it reads and how many of the table 
partitions it selects,
+ *       see {@link HboScanDescriptor});</li>
+ *   <li>for filter / join / aggregate: the normalized predicates / join 
conditions / grouping keys,
+ *       i.e. the structural pattern only (slot and expression ids are 
stripped by
+ *       {@link #normalizeExpression}).</li>
+ * </ul>
+ * The string contains no per-query identifier, so it is reproducible across 
runs and queries for
+ * structurally identical sub trees, and its sha256 is used as the HBO cache 
key (fingerprint).
+ *
+ * <p>The sha256 is taken over the canonical string <b>without</b> the scan 
baselines
+ * ({@link #stripScanBaseline}): a hbo key identifies a plan pattern and the 
relation it reads, not a
+ * data state, so an entry whose table simply keeps growing stays matchable 
instead of silently never
+ * matching again. Whether an entry may still be applied is decided on the 
read side, by comparing
+ * the baseline of the entry with the data state of the current query (see
+ * {@link HboStructFreshness}).
+ *
+ * <p>The descriptor is derived from the group's logical expression and its 
child groups (memo level
+ * traversal, with a visited set to guard shared sub graphs such as CTE). 
Groups whose content is not
+ * supported (e.g. contains TVF / CTE consumer, or the head operator is not 
one of the supported
+ * kinds) are {@link #isValid() invalid} and callers must fall back to the 
legacy behavior.
+ */
+public class GroupStructInfo {
+    private static final List<HboScanDescriptor> INVALID_SCANS = 
Collections.emptyList();
+
+    /** Shared invalid instance: the group content is not supported by the 
simplified struct info. */
+    public static final GroupStructInfo INVALID = new GroupStructInfo(false, 
"", "", "", INVALID_SCANS);
+    /**
+     * Shared instance for a failure which is not a property of the plan: the 
table state could not be
+     * read (e.g. the visible version of a cloud table could not be fetched). 
Unlike
+     * {@link #INVALID} it is not cached by the memo (see {@code 
Group#getOrComputeHboStructInfo}), so
+     * the next lookup of the same group retries instead of disabling hbo 
struct info for the whole
+     * query.
+     */
+    public static final GroupStructInfo TRANSIENT_FAILURE =
+            new GroupStructInfo(false, "", "", "", INVALID_SCANS);
+
+    private static final String SEP = ";";
+    private static final Logger LOG = 
LogManager.getLogger(GroupStructInfo.class);
+
+    /**
+     * Whether literal values participate in the canonical string / 
fingerprint.
+     * <ul>
+     *   <li>{@link #WITH_LITERAL} keeps the literal value ({@code 
lit(10:int)}): the fingerprint is
+     *       bound to the exact constants, used for filter roots where the 
constant changes the
+     *       output row count;</li>
+     *   <li>{@link #NO_LITERAL} replaces every literal value with {@code *} 
({@code lit(*)}): the
+     *       fingerprint is constant agnostic, used for join / aggregate roots 
and for the
+     *       constant-free filter shape.</li>
+     * </ul>
+     * The literal data type is intentionally not kept: nereids inserts an 
explicit {@code Cast}
+     * for mismatching operand types, so two semantically different literal 
forms already differ by
+     * the cast node, while equivalent forms (e.g. {@code d > 1} and {@code d 
> 1.0} on a decimal
+     * column) are allowed to collapse.
+     */
+    public enum LiteralMode {
+        WITH_LITERAL,
+        NO_LITERAL
+    }
+
+    private final boolean valid;
+    private final String canonicalString;
+    /** The canonical string without the scan baselines: the fingerprint 
input. */
+    private final String shapeString;
+    private final String fingerprint;
+    /** The scans of the sub tree, in the order their tokens appear in the 
canonical string. */
+    private final List<HboScanDescriptor> scans;
+    /** Why no struct info was built, when it was refused by a limit instead 
of being unsupported. */
+    private final String skipReason;
+
+    private GroupStructInfo(boolean valid, String canonicalString, String 
shapeString, String fingerprint,
+            List<HboScanDescriptor> scans) {
+        this(valid, canonicalString, shapeString, fingerprint, scans, "");
+    }
+
+    private GroupStructInfo(boolean valid, String canonicalString, String 
shapeString, String fingerprint,
+            List<HboScanDescriptor> scans, String skipReason) {
+        this.valid = valid;
+        this.canonicalString = canonicalString;
+        this.shapeString = shapeString;
+        this.fingerprint = fingerprint;
+        this.scans = scans;
+        this.skipReason = skipReason;
+    }
+
+    /** The reason the struct info was refused by a limit, or empty. Only for 
diagnostics. */
+    public String getSkipReason() {
+        return skipReason;
+    }
+
+    public boolean isValid() {
+        return valid;
+    }
+
+    public String getCanonicalString() {
+        return canonicalString;
+    }
+
+    /**
+     * The fingerprint input: the canonical string with the baseline of every 
scan token removed
+     * ({@code S{db.t}}). It is built from the traversal itself and not by 
parsing the canonical
+     * string, so no text inside a table name or a literal can be mistaken for 
a scan token.
+     */
+    public String getShapeString() {
+        return shapeString;
+    }
+
+    /**
+     * The row count and partition selection of every scan of the sub tree, in 
the order the scan
+     * tokens appear in the canonical string (i.e. the order a user sees when 
pasting the struct
+     * info). Empty when the struct info is invalid.
+     */
+    public List<HboScanDescriptor> getScans() {
+        return scans;
+    }
+
+    public String getFingerprint() {
+        return fingerprint;
+    }
+
+    /**
+     * Resolve the fingerprint of the memo group that {@code planNode} belongs 
to, when the node
+     * still carries its {@link GroupExpression} back reference (memo inner 
plans, or plans from
+     * {@code chooseBestPlan}). Empty when the group content is unsupported 
(invalid struct info).
+     */
+    public static Optional<String> fingerprintOfPlanNode(AbstractPlan 
planNode) {
+        return fingerprintOfPlanNode(planNode, LiteralMode.WITH_LITERAL);
+    }
+
+    /**
+     * Resolve the group fingerprint of a plan node in the given literal mode.
+     */
+    public static Optional<String> fingerprintOfPlanNode(AbstractPlan 
planNode, LiteralMode mode) {
+        return fingerprintOfGroup(planNode.getGroupExpression()
+                .map(GroupExpression::getOwnerGroup).orElse(null), mode);
+    }
+
+    /**
+     * Resolve the fingerprint of the group that {@code planNode} belongs to, 
with a fallback to
+     * the {@link MutableState#KEY_GROUP} group-id state that post processors 
propagate to their
+     * rewritten copies (see {@code copyStatsAndGroupIdFrom}). {@code 
groupsById} maps memo group
+     * id to group, and is looked up only when the back reference is absent.
+     */
+    public static Optional<String> fingerprintOfPlanNode(AbstractPlan 
planNode, Map<Integer, Group> groupsById) {
+        return fingerprintOfPlanNode(planNode, groupsById, 
LiteralMode.WITH_LITERAL);
+    }
+
+    /**
+     * Resolve the group fingerprint of a plan node in the given literal mode, 
with the
+     * {@link MutableState#KEY_GROUP} fallback for post-processed copies.
+     */
+    public static Optional<String> fingerprintOfPlanNode(AbstractPlan 
planNode, Map<Integer, Group> groupsById,
+            LiteralMode mode) {
+        return structInfoOfPlanNode(planNode, groupsById, 
mode).map(GroupStructInfo::getFingerprint);
+    }
+
+    private static Optional<String> fingerprintOfGroup(Group group, 
LiteralMode mode) {
+        return structInfoOfGroup(group, 
mode).map(GroupStructInfo::getFingerprint);
+    }
+
+    /**
+     * Resolve the {@link GroupStructInfo} (with canonical string and 
fingerprint) of the group a
+     * plan node belongs to, using the group-expression back reference or the 
KEY_GROUP group-id
+     * state propagated by post processors (see {@link 
#fingerprintOfPlanNode}).
+     */
+    public static Optional<GroupStructInfo> structInfoOfPlanNode(AbstractPlan 
planNode,
+            Map<Integer, Group> groupsById) {
+        return structInfoOfPlanNode(planNode, groupsById, 
LiteralMode.WITH_LITERAL);
+    }
+
+    /**
+     * Resolve the struct info of the group a plan node belongs to, in the 
given literal mode.
+     */
+    public static Optional<GroupStructInfo> structInfoOfPlanNode(AbstractPlan 
planNode,
+            Map<Integer, Group> groupsById, LiteralMode mode) {
+        Group group = 
planNode.getGroupExpression().map(GroupExpression::getOwnerGroup).orElse(null);
+        if (group == null) {
+            Optional<Object> groupState = 
planNode.getMutableState(MutableState.KEY_GROUP);
+            if (groupState.isPresent() && groupsById != null) {
+                try {
+                    group = 
groupsById.get(Integer.valueOf(groupState.get().toString()));
+                } catch (NumberFormatException ignored) {
+                    group = null;
+                }
+            }
+        }
+        return structInfoOfGroup(group, mode);
+    }
+
+    private static Optional<GroupStructInfo> structInfoOfGroup(Group group, 
LiteralMode mode) {
+        if (group == null) {
+            return Optional.empty();
+        }
+        GroupStructInfo structInfo = group.getOrComputeHboStructInfo(mode);
+        return structInfo.isValid() ? Optional.of(structInfo) : 
Optional.empty();
+    }
+
+    /**
+     * The struct info of the group a plan node belongs to, computed now 
instead of being taken from
+     * the per-group cache. The fingerprint does not depend on any data state, 
so the cached struct
+     * info is the right source for a lookup; its <b>baseline</b> (the visible 
version and the row
+     * counts of its scans) is however a snapshot of the moment it was 
computed, which may be well
+     * before the query is planned. Whoever displays that baseline (EXPLAIN 
prints it as the
+     * {@code struct=} a user pastes into {@code HBO SET STATISTICS}) or 
compares it (the drift
+     * verdict) must therefore ask for it here, so that the whole query 
reports one data state.
+     */
+    public static Optional<GroupStructInfo> dataStateOfPlanNode(AbstractPlan 
planNode,
+            Map<Integer, Group> groupsById, LiteralMode mode) {
+        Group group = 
planNode.getGroupExpression().map(GroupExpression::getOwnerGroup).orElse(null);
+        if (group == null) {
+            Optional<Object> groupState = 
planNode.getMutableState(MutableState.KEY_GROUP);
+            if (groupState.isPresent() && groupsById != null) {
+                try {
+                    group = 
groupsById.get(Integer.valueOf(groupState.get().toString()));
+                } catch (NumberFormatException ignored) {
+                    group = null;
+                }
+            }
+        }
+        if (group == null) {
+            return Optional.empty();
+        }
+        GroupStructInfo structInfo = of(group, mode);
+        return structInfo.isValid() ? Optional.of(structInfo) : 
Optional.empty();
+    }
+
+    /**
+     * Compute the simplified struct info (and its fingerprint) of a memo 
group, by traversing the
+     * group's logical expression and its child groups.
+     */
+    public static GroupStructInfo of(Group group) {
+        return of(group, LiteralMode.WITH_LITERAL);
+    }
+
+    /**
+     * Compute the simplified struct info of a memo group in the given literal 
mode.
+     */
+    public static GroupStructInfo of(Group group, LiteralMode mode) {
+        HboStructSummary summary = group.getOrComputeHboStructSummary();
+        int maxScansPerGroup = hboMaxScansPerGroup();
+        if (maxScansPerGroup > 0 && summary.getScanCount() > maxScansPerGroup) 
{
+            // The canonical string of a group describes its whole sub tree, 
and for a chain of joins
+            // it is built once per group of that chain, so a cheap structural 
summary is used as a
+            // gate: a sub tree with more scan tokens than 
hbo_max_scans_per_group is not described at
+            // all (the read side falls back to the optimizer estimation and 
EXPLAIN reports why).
+            return new GroupStructInfo(false, "", "", "", INVALID_SCANS, 
"subtree scans="
+                    + summary.getScanCount() + " > hbo_max_scans_per_group=" + 
maxScansPerGroup);
+        }
+        Ctx ctx = new Ctx(mode);
+        try {
+            Canonical out = new Canonical();
+            visit(group, out, ctx);
+            if (!ctx.valid) {
+                // a failure which came from reading the catalog (e.g. a cloud 
rpc error) may survive
+                // a later attempt: INVALID would be cached by the memo group 
and disable hbo for the
+                // whole query, TRANSIENT_FAILURE is retried instead
+                return ctx.retryable ? TRANSIENT_FAILURE : INVALID;
+            }
+            String canonicalString = out.annotated.toString();
+            String shapeString = out.shape.toString();
+            String fingerprint = Hashing.sha256()
+                    .hashString(shapeString, 
StandardCharsets.UTF_8).toString();
+            return new GroupStructInfo(true, canonicalString, shapeString, 
fingerprint,
+                    new ArrayList<>(out.getScans()));
+        } catch (RuntimeException e) {
+            // memo content not supported by the simplified struct info: treat 
as invalid and
+            // fall back to legacy behavior instead of failing the optimizer 
on the hot path
+            LOG.debug("failed to compute hbo struct info for group {}", 
group.getGroupId(), e);
+            return ctx.retryable ? TRANSIENT_FAILURE : INVALID;
+        }
+    }
+
+    /**
+     * Visit a group and append its canonical description to {@code sb}.
+     */
+    private static void visit(Group group, Canonical out, Ctx ctx) {
+        if (!ctx.valid) {
+            return;
+        }
+        if (!ctx.visited.add(group)) {
+            // shared sub graph (e.g. CTE / repeated child group): cannot be 
expressed by a single
+            // canonical tree, mark invalid so that callers fall back to 
legacy behavior
+            invalid(ctx);
+            return;
+        }
+        // Use the first logical expression to know the kind of the group 
head. The canonical forms
+        // below are insensitive to which expression is picked, so this choice 
cannot change the
+        // fingerprint of the group (see the class comment).
+        GroupExpression ge = group.getFirstLogicalExpression();
+        if (ge == null) {
+            invalid(ctx);
+            return;
+        }
+        appendPlan(ge.getPlan(), ge, out, ctx);
+    }
+
+    private static void appendPlan(Plan plan, GroupExpression ge, Canonical 
out, Ctx ctx) {
+        if (!ctx.valid) {
+            return;
+        }
+        if (plan instanceof LogicalOlapScan) {
+            appendScan((LogicalOlapScan) plan, out, ctx);
+        } else if (plan instanceof LogicalFilter) {
+            LogicalFilter<?> filter = (LogicalFilter<?>) plan;
+            out.append("F{").append(normalizedSorted(filter.getConjuncts(), 
ctx.mode)).append("}(");
+            appendChild(ge, 0, out, ctx);
+            out.append(")");
+        } else if (plan instanceof LogicalProject) {
+            // project is transparent for structure matching
+            if (ge.arity() == 1) {
+                appendChild(ge, 0, out, ctx);
+            } else {
+                invalid(ctx);
+            }
+        } else if (plan instanceof LogicalJoin) {
+            appendJoin((LogicalJoin<?, ?>) plan, ge, out, ctx);
+        } else if (plan instanceof LogicalAggregate) {
+            appendAggregate((LogicalAggregate<?>) plan, ge, out, ctx);
+        } else {
+            // unsupported head operator 
(sort/topn/limit/window/union/cte/tvf/...)
+            invalid(ctx);
+        }
+    }
+
+    private static void appendJoin(LogicalJoin<?, ?> join, GroupExpression ge, 
Canonical out, Ctx ctx) {
+        if (isFlattenableJoinType(join.getJoinType())) {
+            appendJoinChain(join, ge, out, ctx);
+            return;
+        }
+        // Outer / semi / anti / asof joins: the two sides are semantically 
different and the join
+        // cannot be reassociated, so the memo order of the children and the 
join type are kept.
+        out.append("J{").append(join.getJoinType().toString());
+        out.append(",c:[").append(normalizedJoinConditions(join, 
ctx.mode)).append("]}(");
+        appendChild(ge, 0, out, ctx);
+        out.append(SEP);
+        appendChild(ge, 1, out, ctx);
+        out.append(")");
+    }
+
+    /**
+     * Append the canonical form of a whole inner / cross join chain as a 
single node.
+     *
+     * <p>Inner and cross joins are associative and commutative, so the 
grouping of the chain is an
+     * artifact of the explored expression and must not be part of the 
fingerprint: the chain is
+     * emitted as {@code J{inner,c:[...]}(leaf;leaf;...)} with the conditions 
of every join of the
+     * chain merged into one sorted set and the leaves (scans, filters, 
aggregations, non
+     * flattenable joins) sorted by their canonical form. Every expression of 
a group therefore
+     * produces the same descriptor. Cross joins are merged with inner joins 
because a cross join is
+     * exactly an inner join without condition, and both have the same output 
row count.
+     */
+    private static void appendJoinChain(LogicalJoin<?, ?> join, 
GroupExpression ge, Canonical out, Ctx ctx) {
+        TreeSet<String> conditions = new TreeSet<>();
+        List<Leaf> leaves = new ArrayList<>();
+        collectJoinConditions(join, conditions, ctx.mode);
+        for (int i = 0; i < ge.arity(); i++) {
+            collectJoinChain(ge.child(i), conditions, leaves, ctx);
+        }
+        if (!ctx.valid) {
+            return;
+        }
+        // the leaves are ordered by their fingerprint input (their own shape, 
i.e. without the data
+        // state of their scans): the order is part of the fingerprint, so it 
must not depend on a
+        // data state which the fingerprint deliberately ignores
+        leaves.sort(Comparator.comparing(Leaf::getShape));
+        out.append("J{inner,c:[").append(String.join(SEP, 
conditions)).append("]}(");
+        for (int i = 0; i < leaves.size(); i++) {
+            if (i > 0) {
+                out.append(SEP);
+            }
+            out.append(leaves.get(i).getCanonical());
+        }
+        out.append(")");
+    }
+
+    /**
+     * Collect one member of an inner / cross join chain: either descend into 
another join of the
+     * chain (associativity), or append the canonical form of the whole sub 
tree as one chain leaf.
+     * The group of {@code ge} is already marked visited by the caller.
+     */
+    private static void collectJoinChain(Group group, TreeSet<String> 
conditions, List<Leaf> leaves, Ctx ctx) {
+        if (!ctx.valid) {
+            return;
+        }
+        if (!ctx.visited.add(group)) {
+            invalid(ctx);
+            return;
+        }
+        GroupExpression ge = group.getFirstLogicalExpression();
+        if (ge == null) {
+            invalid(ctx);
+            return;
+        }
+        Plan plan = ge.getPlan();
+        if (plan instanceof LogicalJoin && 
isFlattenableJoinType(((LogicalJoin<?, ?>) plan).getJoinType())) {
+            collectJoinConditions((LogicalJoin<?, ?>) plan, conditions, 
ctx.mode);
+            for (int i = 0; i < ge.arity(); i++) {
+                collectJoinChain(ge.child(i), conditions, leaves, ctx);
+            }
+            return;
+        }
+        if (plan instanceof LogicalProject && ge.arity() == 1) {
+            // keep descending through projects so that a project between two 
joins cannot break the
+            // chain in one expression and not in another
+            collectJoinChain(ge.child(0), conditions, leaves, ctx);
+            return;
+        }
+        Canonical leaf = new Canonical();
+        appendPlan(plan, ge, leaf, ctx);
+        if (ctx.valid) {
+            leaves.add(new Leaf(leaf));
+        }
+    }
+
+    /** One leaf of a flattened join chain: both of its canonical forms and 
its scans. */
+    private static class Leaf {
+        private final Canonical canonical;
+
+        Leaf(Canonical canonical) {
+            this.canonical = canonical;
+        }
+
+        Canonical getCanonical() {
+            return canonical;
+        }
+
+        String getShape() {
+            return canonical.shape.toString();
+        }
+    }
+
+    /** Inner and cross joins may be reordered, reassociated and therefore 
flattened. */
+    private static boolean isFlattenableJoinType(JoinType joinType) {
+        return joinType == JoinType.INNER_JOIN || joinType == 
JoinType.CROSS_JOIN;
+    }
+
+    private static void appendAggregate(LogicalAggregate<?> agg, 
GroupExpression ge, Canonical out, Ctx ctx) {
+        // Only the grouping keys and the child take part: the output row 
count of an aggregation is
+        // the number of groups, which the aggregate functions and the output 
expressions cannot
+        // change, so keeping them would only split one plan pattern into 
several cache entries.
+        
out.append("A{gb:").append(normalizedSorted(agg.getGroupByExpressions(), 
ctx.mode));
+        out.append("}(");
+        appendChild(ge, 0, out, ctx);
+        out.append(")");
+    }
+
+    private static void invalid(Ctx ctx) {
+        ctx.valid = false;
+    }
+
+    /** Mark the struct info invalid because the table state could not be read 
(retryable). */
+    private static void transientFailure(Ctx ctx) {
+        ctx.valid = false;
+        ctx.retryable = true;
+    }
+
+    private static void appendScan(LogicalOlapScan scan, Canonical out, Ctx 
ctx) {
+        try {
+            // the scan token is the table plus the data state of the scan 
(baseline). Its shape form
+            // is the table alone and is written by this very call, so the 
fingerprint input is never
+            // derived by parsing the canonical string: no table name and no 
literal value can be
+            // mistaken for a scan token.
+            HboScanDescriptor descriptor = HboScanDescriptor.of(scan);
+            if (descriptor.getTable().indexOf(',') >= 0 || 
descriptor.getTable().indexOf('}') >= 0) {
+                // the token delimiters cannot appear in a table name: the 
printed struct info could
+                // not be read back, and a pasted one could resolve to another 
table
+                LOG.debug("scan table {} contains a struct info delimiter, hbo 
is disabled for it",
+                        descriptor.getTable());
+                invalid(ctx);
+                return;
+            }
+            out.annotated.append("S{").append(descriptor.render()).append('}');
+            out.shape.append("S{").append(descriptor.getTable()).append('}');
+            out.getScans().add(descriptor);
+        } catch (org.apache.doris.rpc.RpcException e) {
+            // the table version may not be readable (e.g. a cloud rpc 
failure): the group stays
+            // invalid for this lookup, but the failure is retried by the next 
one
+            LOG.debug("failed to get visible version for scan {}", 
scan.getTable().getNameWithFullQualifiers(), e);
+            transientFailure(ctx);
+        }
+    }
+
+    /**
+     * Remove the baseline of every scan token of a canonical string ({@code 
S{db.t,v3,r1000,p1/5}}
+     * becomes {@code S{db.t}}), i.e. the data state of the scan - visible 
version, scanned rows and
+     * pruned partition count.
+     *
+     * <p>The fingerprint of a plan and the sort key of a join chain leaf are 
built by the traversal
+     * itself, which knows where every scan token starts and ends. This text 
based variant is only for
+     * the struct info a user pasted into {@code HBO SET STATISTICS}, whose 
sha256 has to match the
+     * fingerprint the user copied. A literal value which looks like a scan 
token (a string which
+     * starts like one and contains a comma) cannot be told apart from one 
there, so such a struct
+     * info is rejected: that statement cannot be used for that node, in 
either literal mode
+     * (there is no workaround). That is a rejected statement, never a wrong 
key - the fingerprint of
+     * a plan is not computed by this method. A table name which contains the 
delimiters is not
+     * printed at all, because such a canonical form could not be read back 
(see the scan token in
+     * {@code appendScan}).
+     */
+    public static String stripScanBaseline(String canonicalString) {
+        StringBuilder sb = new StringBuilder(canonicalString.length());
+        int index = 0;
+        while (true) {
+            int start = HboScanDescriptor.nextScanTokenStart(canonicalString, 
index);
+            int end = start < 0 ? -1 : canonicalString.indexOf('}', start);
+            if (end < 0) {
+                sb.append(canonicalString, index, canonicalString.length());
+                break;
+            }
+            String header = canonicalString.substring(start + 2, end);
+            int comma = header.indexOf(',');
+            sb.append(canonicalString, index, start).append("S{")
+                    .append(comma < 0 ? header : header.substring(0, 
comma)).append('}');
+            index = end + 1;
+        }
+        return sb.toString();
+    }
+
+    private static void appendChild(GroupExpression ge, int index, Canonical 
out, Ctx ctx) {
+        if (!ctx.valid) {
+            return;
+        }
+        // the child is built on its own form, so that its scans are appended 
in the order its tokens
+        // appear in the parent string
+        Canonical child = new Canonical();
+        visit(ge.child(index), child, ctx);
+        out.append(child);
+    }
+
+    // 
-----------------------------------------------------------------------------------
+    // expression normalization: strip slot/expr ids, keep qualifier + column 
+ literal value
+    // 
-----------------------------------------------------------------------------------
+
+    private static String normalizedSorted(Set<Expression> conjuncts, 
LiteralMode mode) {
+        return conjuncts.stream().map(e -> normalizeExpression(e, 
mode)).sorted()
+                .collect(Collectors.joining(SEP));
+    }
+
+    private static String normalizedSorted(List<Expression> conjuncts, 
LiteralMode mode) {
+        return conjuncts.stream().map(e -> normalizeExpression(e, 
mode)).sorted()
+                .collect(Collectors.joining(SEP));
+    }
+
+    /**
+     * Render all conditions of one join node (hash and other conjuncts) as 
one sorted, de
+     * duplicated set: whether a given conjunct is classified as a hash 
conjunct or as an other
+     * conjunct depends on the grouping of the chain, so that classification 
cannot be part of a
+     * canonical form that has to be insensitive to the grouping.
+     */
+    private static String normalizedJoinConditions(LogicalJoin<?, ?> join, 
LiteralMode mode) {
+        TreeSet<String> conditions = new TreeSet<>();
+        collectJoinConditions(join, conditions, mode);
+        return String.join(SEP, conditions);
+    }
+
+    private static void collectJoinConditions(LogicalJoin<?, ?> join, 
Set<String> conditions, LiteralMode mode) {
+        for (Expression conjunct : join.getHashJoinConjuncts()) {
+            conditions.add(normalizeExpression(conjunct, mode));
+        }
+        for (Expression conjunct : join.getOtherJoinConjuncts()) {
+            conditions.add(normalizeExpression(conjunct, mode));
+        }
+    }
+
+    /**
+     * Rewrite every literal of a canonical string into its constant agnostic 
form ({@code lit(*)}),
+     * i.e. turn a WITH_LITERAL canonical string into the NO_LITERAL one. Used 
to accept a struct info
+     * which a user copied from a filter node of EXPLAIN for the constant 
agnostic fingerprint of the
+     * same node.
+     *
+     * <p>A literal value is not quoted in the canonical form and a data type 
may even contain
+     * parentheses ({@code lit(abc:VARCHAR(10))}), so the closing parenthesis 
is found by counting
+     * instead of by a regular expression; a value with balanced parentheses
+     * ({@code lit(IL (north))}) is therefore folded correctly. Only an 
<b>unbalanced</b> parenthesis
+     * in a value (e.g. {@code where city = 'IL)'}) makes the fold consume too 
much, and the pasted
+     * struct info is then rejected by the fingerprint check of {@code HBO SET 
STATISTICS} with the
+     * generic "does not match the fingerprint" message; there is no 
workaround, because the check
+     * runs in both literal modes. The constant agnostic struct info printed 
by EXPLAIN is built by the
+     * group traversal itself (see {@link #dataStateOfPlanNode}), so it has no 
such limitation.
+     */
+    public static String toNoLiteral(String canonicalString) {
+        StringBuilder sb = new StringBuilder(canonicalString.length());
+        int index = 0;
+        while (index < canonicalString.length()) {
+            int start = canonicalString.indexOf("lit(", index);
+            if (start < 0) {
+                sb.append(canonicalString, index, canonicalString.length());
+                break;
+            }
+            sb.append(canonicalString, index, start).append("lit(*)");
+            int depth = 0;
+            int cursor = start + 3;
+            while (cursor < canonicalString.length()) {
+                char c = canonicalString.charAt(cursor);
+                if (c == '(') {
+                    depth++;
+                } else if (c == ')') {
+                    depth--;
+                    if (depth == 0) {
+                        cursor++;
+                        break;
+                    }
+                }
+                cursor++;
+            }
+            index = cursor;
+        }
+        return sb.toString();
+    }
+
+    /**
+     * Normalize a single expression into its canonical component (slot -&gt; 
{@code col(qualifier.name)},
+     * literal -&gt; {@code lit(value:type)} / {@code lit(*)}, other nodes 
-&gt; {@code ClassName(children)}).
+     * Shared with the hbo join-condition canonicalizer.
+     */
+    public static String normalizeExpression(Expression expression) {
+        return normalizeExpression(expression, LiteralMode.WITH_LITERAL);
+    }
+
+    private static String normalizeExpression(Expression expression, 
LiteralMode mode) {
+        if (expression instanceof SlotReference) {
+            SlotReference slot = (SlotReference) expression;
+            return "col(" + String.join(".", slot.getQualifier()) + "." + 
slot.getName() + ")";
+        } else if (expression instanceof Literal) {
+            if (mode == LiteralMode.NO_LITERAL) {
+                return "lit(*)";
+            }
+            Literal literal = (Literal) expression;
+            return "lit(" + literal.getValue() + ":" + literal.getDataType() + 
")";
+        } else if (expression.children().isEmpty()) {
+            return expression.getClass().getSimpleName();
+        } else {
+            List<Expression> children = expression.children();
+            List<String> normalizedChildren = children.stream()
+                    .map(e -> normalizeExpression(e, 
mode)).collect(Collectors.toList());
+            if (isOrderInsensitive(expression)) {
+                // only order-insensitive operators (equal-to / and / or) may 
have their operands
+                // sorted; ordered comparisons (e.g. '<') must keep the 
original order, otherwise
+                // `col < 5` and `5 < col` would collapse to the same 
descriptor (review B2).
+                normalizedChildren.sort(String::compareTo);
+            }
+            String childrenStr = String.join(",", normalizedChildren);
+            return expression.getClass().getSimpleName() + "(" + childrenStr + 
")";

Review Comment:
   认可:expression canonical 目前只用类名与 children,同名参数的 Java/Python UDF 会撞 key。需要在 
canonical 中加入函数标识(name/database/signature/id)。本轮未做,计划下一步处理。



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/HboPlanStatisticsManager.java:
##########
@@ -38,4 +187,415 @@ public HboPlanStatisticsProvider 
getHboPlanStatisticsProvider() {
     public HboPlanInfoProvider getHboPlanInfoProvider() {
         return hboPlanInfoProvider;
     }
+
+    /**
+     * Inject (or overwrite) a pinned statistics entry applied unconditionally.
+     * @param fingerprint hbo fingerprint (simplified group struct info sha256)
+     * @param rows output row count that overrides the optimizer estimation
+     * @param structCanonical optional human-readable simplified struct info 
canonical string
+     */
+    public void putPinnedPlanStatistics(String fingerprint, long rows, String 
structCanonical) {
+        putPinnedPlanStatistics(fingerprint, rows, PinnedType.EXACT, 
structCanonical, LiteralMode.NO_LITERAL);
+    }
+
+    /**
+     * Inject (or overwrite) a pinned statistics entry.
+     * @param type how the injected row count is applied (unconditional vs 
filter-small guard)
+     * @param structCanonical optional human-readable simplified struct info 
canonical string
+     */
+    public void putPinnedPlanStatistics(String fingerprint, long rows, 
PinnedType type, String structCanonical,
+            LiteralMode literalMode) {
+        putPinnedStatistics(fingerprint, rows, type, structCanonical, 0, 
literalMode);
+    }
+
+    /**
+     * Inject (or overwrite) a pinned statistics entry.
+     *
+     * @param expansion fan-out factor, only for {@link 
PinnedType#JOIN_EXPANSION} entries
+     */
+    private void putPinnedStatistics(String fingerprint, long rows, PinnedType 
type, String structCanonical,
+            double expansion, LiteralMode literalMode) {
+        // LRU bounded by Config.hbo_pinned_stats_cache_num; pinned entries 
are otherwise never
+        // expired automatically and are only removed by HBO DELETE STATISTICS 
or eviction
+        long createTimeMs = System.currentTimeMillis();
+        synchronized (pinnedLoadLock) {
+            pinnedPlanStatistics.put(fingerprint,
+                    new PinnedHboStatistics(fingerprint, rows, type, 
structCanonical, expansion, literalMode,
+                            createTimeMs));
+            if (type != PinnedType.JOIN_EXPANSION) {
+                // only row count entries are looked up by relation key: a 
join expansion entry is
+                // keyed by the join conditions, is rejected by 
getPinnedPlanStatistics, and its
+                // scanless canonical would turn the pre filter into a no-op 
for every group
+                registerRelationKey(structCanonical);
+            }
+            // a SET after a failed DELETE re-creates the entry: drop the 
deletion intent so a
+            // pending load does not skip the re-created row
+            pendingLoadTombstones.remove(fingerprint);
+            // persist under the same lock as DELETE's DB removal, so 
concurrent same-key
+            // SET/DELETE serialize their DB writes in the same order as their 
memory updates
+            // and, when the best-effort writes succeed, the stored row 
matches the last memory
+            // writer (a failed write only logs a warning and may reappear 
after a FE restart)
+            if (persistenceEnabled()) {
+                HboStatisticsStore.persist(fingerprint, rows, type, 
structCanonical, expansion, literalMode,
+                        createTimeMs);
+            }
+        }
+    }
+
+    /**
+     * The pinned row-count entry of a sub tree. Entries of another kind (a 
join expansion factor
+     * keyed by the join conditions) are never returned here, so the two read 
paths can not pick up
+     * each other's entry.
+     *
+     * <p>This is the planning hot path, so it only reads the in-memory cache: 
the persisted entries
+     * of this FE are loaded by {@link HboPinnedStatisticsLoader} in the 
background. Doing it here
+     * would run a DDL and a full table scan on a user query's planning 
thread, and the internal
+     * statement it runs would re-enter this manager (see the class javadoc).
+     */
+    public Optional<PinnedHboStatistics> getPinnedPlanStatistics(String 
fingerprint) {
+        return 
Optional.ofNullable(pinnedPlanStatistics.getIfPresent(fingerprint))
+                .filter(pinned -> !pinned.isExpansion());
+    }
+
+    /**
+     * Whether this FE holds any pinned entry at all (of any kind). The 
planning path looks an entry
+     * up by the hbo fingerprint of a plan node, and that fingerprint is 
expensive to build (a whole
+     * memo subtree traversal); an empty cache means every such lookup is a 
guaranteed miss, so the
+     * caller can skip building the fingerprint. Like {@link 
#getPinnedPlanStatistics(String)} this
+     * only reads the in-memory cache and never triggers the background loader.
+     */
+    public boolean hasAnyPinnedStatistics() {
+        return !pinnedPlanStatistics.asMap().isEmpty();
+    }
+
+    /**
+     * Whether a pinned entry could possibly apply to a sub tree which reads 
exactly {@code
+     * relationKey} (see {@link 
GroupStructInfo.HboStructSummary#getRelationKey()}).
+     *
+     * <p>An entry is keyed by the fingerprint of its struct info, and two 
equal struct infos
+     * necessarily read the same tables, so an entry whose relation key 
differs can not match: the
+     * caller can skip building the (expensive) fingerprint. The index is an 
over approximation -
+     * keys are only ever added, so a removed or evicted entry may keep its 
key - which can only make
+     * a lookup happen, never suppress one.
+     */
+    public boolean mayHavePinnedEntryForRelations(String relationKey) {
+        return pinnedEntriesWithoutRelationKey.get() > 0 || 
pinnedRelationKeys.contains(relationKey);
+    }
+
+    /** Register the relation key of an entry's struct info in the lookup 
index. */
+    private void registerRelationKey(String structCanonical) {
+        List<HboScanDescriptor> scans = 
HboScanDescriptor.parseAll(structCanonical);
+        if (scans.isEmpty()) {
+            // an entry which records no scan at all can not be filtered by a 
relation key
+            pinnedEntriesWithoutRelationKey.incrementAndGet();
+            return;
+        }
+        List<String> tables = new ArrayList<>(scans.size());
+        for (HboScanDescriptor scan : scans) {
+            tables.add(scan.getTable());
+        }
+        Collections.sort(tables);
+        pinnedRelationKeys.add(String.join(",", tables));
+    }
+
+    /**
+     * Remove a pinned statistics entry. Learned entries of the same key (if 
any) are left
+     * untouched here; callers may invalidate them explicitly.
+     */
+    public void removePinnedPlanStatistics(String fingerprint) {
+        synchronized (pinnedLoadLock) {
+            pinnedPlanStatistics.invalidate(fingerprint);
+            if (persistenceEnabled()) {
+                // the DB removal runs under the lock so a lazy load can never 
SELECT the row in
+                // the window between the memory invalidation and its DB 
removal (M-1 R1)
+                if (!HboStatisticsStore.delete(fingerprint) && 
!hboPinnedLoaded) {
+                    // the row is still stored and a pending/retrying load 
would resurrect it
+                    // into memory: remember the deletion intent (M-1 R2)
+                    pendingLoadTombstones.add(fingerprint);
+                }
+            }
+        }
+    }
+
+    /**
+     * Inject (or overwrite) the fan-out factor of a set of join equality 
conditions
+     * ({@code HBO SET STATISTICS ... TYPE=JOIN_EXPANSION}). It is a pinned 
entry like every other
+     * hbo statistics entry - same table, same lifecycle - only its value is a 
factor instead of a
+     * row count and its key is the join condition fingerprint.
+     *
+     * @param fingerprint sha256 of the canonical equality-condition set
+     * @param expansion measured fan-out factor relative to the larger input 
of the join (0.1 keeps
+     *                  10% of the larger input, 1000 fans out to 1000 times 
it), greater than 0
+     * @param condCanonical the canonical equality-condition set the factor 
was measured for
+     */
+    public void putPinnedExpansionStatistics(String fingerprint, double 
expansion, String condCanonical) {
+        // a condition key never carries a literal by itself: it is the 
constant agnostic form
+        putPinnedStatistics(fingerprint, 0, PinnedType.JOIN_EXPANSION, 
condCanonical, expansion,
+                LiteralMode.NO_LITERAL);
+    }
+
+    /**
+     * The pinned join expansion entry of a set of equality conditions, if one 
was injected.
+     */
+    public Optional<PinnedHboStatistics> getPinnedExpansion(String 
fingerprint) {
+        return 
Optional.ofNullable(pinnedPlanStatistics.getIfPresent(fingerprint))
+                .filter(PinnedHboStatistics::isExpansion);
+    }
+
+    /**
+     * Inject a learned entry (used by {@code HBO SET LEARNED STATISTICS}) so 
that the learned
+     * lookup path can be exercised without waiting for a real profile 
publish. The injected entry
+     * carries no input table statistics and therefore matches by fingerprint 
alone.
+     */
+    public void putLearnedPlanStatistics(String fingerprint, long rows, String 
structCanonical) {
+        PlanStatistics planStatistics = new PlanStatistics(0, rows, rows, 
rows, rows,
+                rows, rows, rows, rows, 0, 0, 1);
+        RecentRunsPlanStatistics runs = new RecentRunsPlanStatistics(
+                Lists.newArrayList(new 
RecentRunsPlanStatisticsEntry(planStatistics, Lists.newArrayList())));
+        hboPlanStatisticsProvider.putHboPlanStatsByFingerprint(fingerprint, 
runs);
+        if (structCanonical != null && !structCanonical.isEmpty()) {
+            learnedStructCanonical.put(fingerprint, structCanonical);
+        }
+    }
+
+    /** The struct info a learned entry was injected with, when it was 
injected with one. */
+    public Optional<String> getLearnedStructCanonical(String fingerprint) {
+        return 
Optional.ofNullable(learnedStructCanonical.getIfPresent(fingerprint));
+    }
+
+    /** Remove a learned entry by fingerprint. */
+    public void removeLearnedPlanStatistics(String fingerprint) {
+        hboPlanStatisticsProvider.removeHboPlanStats(fingerprint);
+        learnedStructCanonical.invalidate(fingerprint);
+    }
+
+    /**
+     * Remove every pinned entry which can never be applied again: an entry 
records the data state it
+     * was measured in (the baseline of the scans of its struct info), and 
once a table moved beyond
+     * {@code Config.hbo_row_count_change_ratio} (or was dropped) the entry is 
not applied any more
+     * (see {@link HboStructFreshness}); it only keeps a cache slot until it 
is removed here. Entries
+     * which are merely {@code drifted} (the data moved, but within the 
tolerance) are kept: they are
+     * still applied.
+     *
+     * @param olderThanMillis when non negative, entries whose state cannot be 
determined (their
+     *                        struct info records no comparable state, e.g. it 
prunes partitions or
+     *                        does not resolve to existing tables) are removed 
as well, but only when
+     *                        they are older than this many milliseconds; a 
negative value keeps them
+     * @return the number of removed entries
+     */
+    public int deleteStalePinnedPlanStatistics(long olderThanMillis) {
+        long now = System.currentTimeMillis();
+        int removed = 0;
+        for (PinnedHboStatistics pinned : 
getAllPinnedPlanStatistics().values()) {
+            // an expansion entry is keyed by join conditions and carries no 
data state, so it can
+            // neither go stale nor be judged by age: OLDER_THAN must not 
silently drop it
+            if (pinned.isExpansion()) {
+                continue;
+            }
+            HboStructFreshness freshness = 
HboStructFreshness.of(pinned.getStructCanonical());
+            boolean obsolete = freshness.isStale()
+                    || (olderThanMillis >= 0 && freshness.isUnknown()
+                            && now - pinned.getCreateTime() > olderThanMillis);
+            if (obsolete) {
+                removePinnedPlanStatistics(pinned.getFingerprint());
+                removed++;
+            }
+        }
+        return removed;
+    }
+
+    /**
+     * Every pinned entry of this FE, after making sure the persisted ones 
have been loaded.
+     *
+     * <p>Unlike the planning path ({@link #getPinnedPlanStatistics}) this is 
only used by the admin
+     * statements ({@code HBO SHOW} / {@code HBO DELETE STALE STATISTICS}), 
which are expected to wait
+     * for the entries of the internal table and to report what is stored. 
Loading here cannot
+     * re-enter the way it could before the load moved to {@link 
HboPinnedStatisticsLoader}: the
+     * internal statement a load runs is planned by this FE, and its planning 
reads the pinned cache
+     * without triggering a load any more.
+     */
+    public Map<String, PinnedHboStatistics> getAllPinnedPlanStatistics() {
+        ensurePinnedLoaded();
+        return Collections.unmodifiableMap(pinnedPlanStatistics.asMap());
+    }
+
+    /**
+     * Start loading the persisted pinned statistics of this FE in the 
background. Called once by
+     * {@code Env} while the daemon threads are started; a FE which never 
loads them (the internal
+     * schema is not ready, or persistence is turned off) simply plans without 
the persisted entries.
+     */
+    public void startPinnedStatisticsLoader() {
+        synchronized (pinnedLoadLock) {
+            if (loader != null && loader.isAlive()) {
+                // the daemon threads are started again when a FE is promoted; 
a second loader would
+                // only double the retry attempts
+                return;
+            }
+            loader = new HboPinnedStatisticsLoader(this);
+            loader.start();
+        }
+        LOG.info("started the hbo pinned statistics loader");
+    }
+
+    /** Whether the persisted pinned statistics of this FE have been loaded 
(or are not needed). */
+    public boolean isPinnedStatisticsLoaded() {
+        return hboPinnedLoaded || !FeConstants.enableInternalSchemaDb;
+    }
+
+    /** Whether the loader has nothing left to do. */
+    boolean needsPinnedStatisticsLoad() {
+        return FeConstants.enableInternalSchemaDb && !hboPinnedLoaded;
+    }
+
+    /** Load the persisted pinned statistics once; a failure is retried by the 
loader. */
+    void loadPinnedStatistics() {
+        ensurePinnedLoaded();
+    }
+
+    private void ensurePinnedLoaded() {
+        if (hboPinnedLoaded || !persistenceEnabled()) {
+            return;
+        }
+        synchronized (pinnedLoadLock) {
+            if (hboPinnedLoaded || !persistenceEnabled()) {
+                return;
+            }
+            long now = System.currentTimeMillis();
+            if (now - lastLoadFailedMs < LOAD_RETRY_INTERVAL_MS) {
+                return;
+            }
+            List<PinnedHboStatistics> loaded = HboStatisticsStore.loadAll();
+            if (loaded == null) {
+                lastLoadFailedMs = now;
+                return;
+            }
+            List<String> suppressedByTombstone = new ArrayList<>();
+            for (PinnedHboStatistics pinned : loaded) {
+                // entries injected while persistence was off, or concurrently 
with the load,
+                // must win over the stored snapshot, and entries deleted 
while the load was
+                // still pending must not be resurrected: the in-memory cache 
stays authoritative
+                if (pendingLoadTombstones.contains(pinned.getFingerprint())) {
+                    suppressedByTombstone.add(pinned.getFingerprint());
+                } else {
+                    
pinnedPlanStatistics.asMap().putIfAbsent(pinned.getFingerprint(), pinned);
+                    if (pinned.getType() != PinnedType.JOIN_EXPANSION) {
+                        registerRelationKey(pinned.getStructCanonical());
+                    }
+                }
+            }
+            hboPinnedLoaded = true;
+            if (!suppressedByTombstone.isEmpty()) {
+                // the load succeeded, so the internal table is reachable: 
retry the failed DB
+                // removals (idempotent) for exactly the rows that were seen 
in the loaded
+                // snapshot, so they do not reappear after this FE restarts. 
Rows another FE
+                // created after the snapshot are not touched; rows it created 
before the
+                // snapshot may be removed (see class javadoc)
+                int failed = 0;
+                for (String fingerprint : suppressedByTombstone) {
+                    if (!HboStatisticsStore.delete(fingerprint)) {

Review Comment:
   认可(P1):DELETE 回放时 fingerprint-only 墓碑会压掉它之后更晚的 SET。需要给墓碑带上时间/版本,只压制更早的 
SET。本轮未做,计划下一步处理。



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/HboPlanStatisticsManager.java:
##########
@@ -38,4 +187,415 @@ public HboPlanStatisticsProvider 
getHboPlanStatisticsProvider() {
     public HboPlanInfoProvider getHboPlanInfoProvider() {
         return hboPlanInfoProvider;
     }
+
+    /**
+     * Inject (or overwrite) a pinned statistics entry applied unconditionally.
+     * @param fingerprint hbo fingerprint (simplified group struct info sha256)
+     * @param rows output row count that overrides the optimizer estimation
+     * @param structCanonical optional human-readable simplified struct info 
canonical string
+     */
+    public void putPinnedPlanStatistics(String fingerprint, long rows, String 
structCanonical) {
+        putPinnedPlanStatistics(fingerprint, rows, PinnedType.EXACT, 
structCanonical, LiteralMode.NO_LITERAL);
+    }
+
+    /**
+     * Inject (or overwrite) a pinned statistics entry.
+     * @param type how the injected row count is applied (unconditional vs 
filter-small guard)
+     * @param structCanonical optional human-readable simplified struct info 
canonical string
+     */
+    public void putPinnedPlanStatistics(String fingerprint, long rows, 
PinnedType type, String structCanonical,
+            LiteralMode literalMode) {
+        putPinnedStatistics(fingerprint, rows, type, structCanonical, 0, 
literalMode);
+    }
+
+    /**
+     * Inject (or overwrite) a pinned statistics entry.
+     *
+     * @param expansion fan-out factor, only for {@link 
PinnedType#JOIN_EXPANSION} entries
+     */
+    private void putPinnedStatistics(String fingerprint, long rows, PinnedType 
type, String structCanonical,
+            double expansion, LiteralMode literalMode) {
+        // LRU bounded by Config.hbo_pinned_stats_cache_num; pinned entries 
are otherwise never
+        // expired automatically and are only removed by HBO DELETE STATISTICS 
or eviction
+        long createTimeMs = System.currentTimeMillis();
+        synchronized (pinnedLoadLock) {
+            pinnedPlanStatistics.put(fingerprint,
+                    new PinnedHboStatistics(fingerprint, rows, type, 
structCanonical, expansion, literalMode,
+                            createTimeMs));
+            if (type != PinnedType.JOIN_EXPANSION) {
+                // only row count entries are looked up by relation key: a 
join expansion entry is
+                // keyed by the join conditions, is rejected by 
getPinnedPlanStatistics, and its
+                // scanless canonical would turn the pre filter into a no-op 
for every group
+                registerRelationKey(structCanonical);
+            }
+            // a SET after a failed DELETE re-creates the entry: drop the 
deletion intent so a
+            // pending load does not skip the re-created row
+            pendingLoadTombstones.remove(fingerprint);
+            // persist under the same lock as DELETE's DB removal, so 
concurrent same-key
+            // SET/DELETE serialize their DB writes in the same order as their 
memory updates
+            // and, when the best-effort writes succeed, the stored row 
matches the last memory
+            // writer (a failed write only logs a warning and may reappear 
after a FE restart)
+            if (persistenceEnabled()) {
+                HboStatisticsStore.persist(fingerprint, rows, type, 
structCanonical, expansion, literalMode,
+                        createTimeMs);
+            }
+        }
+    }
+
+    /**
+     * The pinned row-count entry of a sub tree. Entries of another kind (a 
join expansion factor
+     * keyed by the join conditions) are never returned here, so the two read 
paths can not pick up
+     * each other's entry.
+     *
+     * <p>This is the planning hot path, so it only reads the in-memory cache: 
the persisted entries
+     * of this FE are loaded by {@link HboPinnedStatisticsLoader} in the 
background. Doing it here
+     * would run a DDL and a full table scan on a user query's planning 
thread, and the internal
+     * statement it runs would re-enter this manager (see the class javadoc).
+     */
+    public Optional<PinnedHboStatistics> getPinnedPlanStatistics(String 
fingerprint) {
+        return 
Optional.ofNullable(pinnedPlanStatistics.getIfPresent(fingerprint))
+                .filter(pinned -> !pinned.isExpansion());
+    }
+
+    /**
+     * Whether this FE holds any pinned entry at all (of any kind). The 
planning path looks an entry
+     * up by the hbo fingerprint of a plan node, and that fingerprint is 
expensive to build (a whole
+     * memo subtree traversal); an empty cache means every such lookup is a 
guaranteed miss, so the
+     * caller can skip building the fingerprint. Like {@link 
#getPinnedPlanStatistics(String)} this
+     * only reads the in-memory cache and never triggers the background loader.
+     */
+    public boolean hasAnyPinnedStatistics() {
+        return !pinnedPlanStatistics.asMap().isEmpty();
+    }
+
+    /**
+     * Whether a pinned entry could possibly apply to a sub tree which reads 
exactly {@code
+     * relationKey} (see {@link 
GroupStructInfo.HboStructSummary#getRelationKey()}).
+     *
+     * <p>An entry is keyed by the fingerprint of its struct info, and two 
equal struct infos
+     * necessarily read the same tables, so an entry whose relation key 
differs can not match: the
+     * caller can skip building the (expensive) fingerprint. The index is an 
over approximation -
+     * keys are only ever added, so a removed or evicted entry may keep its 
key - which can only make
+     * a lookup happen, never suppress one.
+     */
+    public boolean mayHavePinnedEntryForRelations(String relationKey) {
+        return pinnedEntriesWithoutRelationKey.get() > 0 || 
pinnedRelationKeys.contains(relationKey);
+    }
+
+    /** Register the relation key of an entry's struct info in the lookup 
index. */
+    private void registerRelationKey(String structCanonical) {
+        List<HboScanDescriptor> scans = 
HboScanDescriptor.parseAll(structCanonical);
+        if (scans.isEmpty()) {
+            // an entry which records no scan at all can not be filtered by a 
relation key
+            pinnedEntriesWithoutRelationKey.incrementAndGet();
+            return;
+        }
+        List<String> tables = new ArrayList<>(scans.size());
+        for (HboScanDescriptor scan : scans) {
+            tables.add(scan.getTable());
+        }
+        Collections.sort(tables);
+        pinnedRelationKeys.add(String.join(",", tables));
+    }
+
+    /**
+     * Remove a pinned statistics entry. Learned entries of the same key (if 
any) are left
+     * untouched here; callers may invalidate them explicitly.
+     */
+    public void removePinnedPlanStatistics(String fingerprint) {
+        synchronized (pinnedLoadLock) {
+            pinnedPlanStatistics.invalidate(fingerprint);
+            if (persistenceEnabled()) {
+                // the DB removal runs under the lock so a lazy load can never 
SELECT the row in
+                // the window between the memory invalidation and its DB 
removal (M-1 R1)
+                if (!HboStatisticsStore.delete(fingerprint) && 
!hboPinnedLoaded) {
+                    // the row is still stored and a pending/retrying load 
would resurrect it
+                    // into memory: remember the deletion intent (M-1 R2)
+                    pendingLoadTombstones.add(fingerprint);
+                }
+            }
+        }
+    }
+
+    /**
+     * Inject (or overwrite) the fan-out factor of a set of join equality 
conditions
+     * ({@code HBO SET STATISTICS ... TYPE=JOIN_EXPANSION}). It is a pinned 
entry like every other
+     * hbo statistics entry - same table, same lifecycle - only its value is a 
factor instead of a
+     * row count and its key is the join condition fingerprint.
+     *
+     * @param fingerprint sha256 of the canonical equality-condition set
+     * @param expansion measured fan-out factor relative to the larger input 
of the join (0.1 keeps
+     *                  10% of the larger input, 1000 fans out to 1000 times 
it), greater than 0
+     * @param condCanonical the canonical equality-condition set the factor 
was measured for
+     */
+    public void putPinnedExpansionStatistics(String fingerprint, double 
expansion, String condCanonical) {
+        // a condition key never carries a literal by itself: it is the 
constant agnostic form
+        putPinnedStatistics(fingerprint, 0, PinnedType.JOIN_EXPANSION, 
condCanonical, expansion,
+                LiteralMode.NO_LITERAL);
+    }
+
+    /**
+     * The pinned join expansion entry of a set of equality conditions, if one 
was injected.
+     */
+    public Optional<PinnedHboStatistics> getPinnedExpansion(String 
fingerprint) {
+        return 
Optional.ofNullable(pinnedPlanStatistics.getIfPresent(fingerprint))
+                .filter(PinnedHboStatistics::isExpansion);
+    }
+
+    /**
+     * Inject a learned entry (used by {@code HBO SET LEARNED STATISTICS}) so 
that the learned
+     * lookup path can be exercised without waiting for a real profile 
publish. The injected entry
+     * carries no input table statistics and therefore matches by fingerprint 
alone.
+     */
+    public void putLearnedPlanStatistics(String fingerprint, long rows, String 
structCanonical) {
+        PlanStatistics planStatistics = new PlanStatistics(0, rows, rows, 
rows, rows,
+                rows, rows, rows, rows, 0, 0, 1);
+        RecentRunsPlanStatistics runs = new RecentRunsPlanStatistics(
+                Lists.newArrayList(new 
RecentRunsPlanStatisticsEntry(planStatistics, Lists.newArrayList())));
+        hboPlanStatisticsProvider.putHboPlanStatsByFingerprint(fingerprint, 
runs);
+        if (structCanonical != null && !structCanonical.isEmpty()) {
+            learnedStructCanonical.put(fingerprint, structCanonical);
+        }
+    }
+
+    /** The struct info a learned entry was injected with, when it was 
injected with one. */
+    public Optional<String> getLearnedStructCanonical(String fingerprint) {
+        return 
Optional.ofNullable(learnedStructCanonical.getIfPresent(fingerprint));
+    }
+
+    /** Remove a learned entry by fingerprint. */
+    public void removeLearnedPlanStatistics(String fingerprint) {
+        hboPlanStatisticsProvider.removeHboPlanStats(fingerprint);
+        learnedStructCanonical.invalidate(fingerprint);
+    }
+
+    /**
+     * Remove every pinned entry which can never be applied again: an entry 
records the data state it
+     * was measured in (the baseline of the scans of its struct info), and 
once a table moved beyond
+     * {@code Config.hbo_row_count_change_ratio} (or was dropped) the entry is 
not applied any more
+     * (see {@link HboStructFreshness}); it only keeps a cache slot until it 
is removed here. Entries
+     * which are merely {@code drifted} (the data moved, but within the 
tolerance) are kept: they are
+     * still applied.
+     *
+     * @param olderThanMillis when non negative, entries whose state cannot be 
determined (their
+     *                        struct info records no comparable state, e.g. it 
prunes partitions or
+     *                        does not resolve to existing tables) are removed 
as well, but only when
+     *                        they are older than this many milliseconds; a 
negative value keeps them
+     * @return the number of removed entries
+     */
+    public int deleteStalePinnedPlanStatistics(long olderThanMillis) {
+        long now = System.currentTimeMillis();
+        int removed = 0;
+        for (PinnedHboStatistics pinned : 
getAllPinnedPlanStatistics().values()) {
+            // an expansion entry is keyed by join conditions and carries no 
data state, so it can
+            // neither go stale nor be judged by age: OLDER_THAN must not 
silently drop it
+            if (pinned.isExpansion()) {
+                continue;
+            }
+            HboStructFreshness freshness = 
HboStructFreshness.of(pinned.getStructCanonical());
+            boolean obsolete = freshness.isStale()
+                    || (olderThanMillis >= 0 && freshness.isUnknown()
+                            && now - pinned.getCreateTime() > olderThanMillis);
+            if (obsolete) {
+                removePinnedPlanStatistics(pinned.getFingerprint());
+                removed++;
+            }
+        }
+        return removed;
+    }
+
+    /**
+     * Every pinned entry of this FE, after making sure the persisted ones 
have been loaded.
+     *
+     * <p>Unlike the planning path ({@link #getPinnedPlanStatistics}) this is 
only used by the admin
+     * statements ({@code HBO SHOW} / {@code HBO DELETE STALE STATISTICS}), 
which are expected to wait
+     * for the entries of the internal table and to report what is stored. 
Loading here cannot
+     * re-enter the way it could before the load moved to {@link 
HboPinnedStatisticsLoader}: the
+     * internal statement a load runs is planned by this FE, and its planning 
reads the pinned cache
+     * without triggering a load any more.
+     */
+    public Map<String, PinnedHboStatistics> getAllPinnedPlanStatistics() {
+        ensurePinnedLoaded();
+        return Collections.unmodifiableMap(pinnedPlanStatistics.asMap());
+    }
+
+    /**
+     * Start loading the persisted pinned statistics of this FE in the 
background. Called once by
+     * {@code Env} while the daemon threads are started; a FE which never 
loads them (the internal
+     * schema is not ready, or persistence is turned off) simply plans without 
the persisted entries.
+     */
+    public void startPinnedStatisticsLoader() {
+        synchronized (pinnedLoadLock) {
+            if (loader != null && loader.isAlive()) {
+                // the daemon threads are started again when a FE is promoted; 
a second loader would
+                // only double the retry attempts
+                return;
+            }
+            loader = new HboPinnedStatisticsLoader(this);
+            loader.start();
+        }
+        LOG.info("started the hbo pinned statistics loader");
+    }
+
+    /** Whether the persisted pinned statistics of this FE have been loaded 
(or are not needed). */
+    public boolean isPinnedStatisticsLoaded() {
+        return hboPinnedLoaded || !FeConstants.enableInternalSchemaDb;
+    }
+
+    /** Whether the loader has nothing left to do. */
+    boolean needsPinnedStatisticsLoad() {
+        return FeConstants.enableInternalSchemaDb && !hboPinnedLoaded;
+    }
+
+    /** Load the persisted pinned statistics once; a failure is retried by the 
loader. */
+    void loadPinnedStatistics() {
+        ensurePinnedLoaded();
+    }
+
+    private void ensurePinnedLoaded() {
+        if (hboPinnedLoaded || !persistenceEnabled()) {
+            return;
+        }
+        synchronized (pinnedLoadLock) {
+            if (hboPinnedLoaded || !persistenceEnabled()) {
+                return;
+            }
+            long now = System.currentTimeMillis();
+            if (now - lastLoadFailedMs < LOAD_RETRY_INTERVAL_MS) {
+                return;
+            }
+            List<PinnedHboStatistics> loaded = HboStatisticsStore.loadAll();
+            if (loaded == null) {
+                lastLoadFailedMs = now;
+                return;
+            }
+            List<String> suppressedByTombstone = new ArrayList<>();
+            for (PinnedHboStatistics pinned : loaded) {
+                // entries injected while persistence was off, or concurrently 
with the load,
+                // must win over the stored snapshot, and entries deleted 
while the load was
+                // still pending must not be resurrected: the in-memory cache 
stays authoritative
+                if (pendingLoadTombstones.contains(pinned.getFingerprint())) {
+                    suppressedByTombstone.add(pinned.getFingerprint());
+                } else {
+                    
pinnedPlanStatistics.asMap().putIfAbsent(pinned.getFingerprint(), pinned);

Review Comment:
   认可:有界缓存的 loadAll 没有保持“当前活动条目”,hbo_pinned_stats_cache_num 很小时重启后可能丢掉在用条目。需要在 
loadAll 里按创建时间保留活动条目。本轮未做,计划下一步处理。



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/HboScanDescriptor.java:
##########
@@ -0,0 +1,342 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.nereids.stats;
+
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.nereids.trees.plans.algebra.OlapScan;
+import org.apache.doris.rpc.RpcException;
+import org.apache.doris.statistics.analysis.TableStatsMeta;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The data state of one scan of a hbo struct info: the table it reads, the 
table visible version,
+ * the number of rows the scan reads (the sum of the row counts of its 
selected partitions) and which
+ * part of the table partitions it selects.
+ *
+ * <p>These numbers are the <b>baseline</b> a hbo entry is judged against. The 
scan token of a
+ * canonical struct info carries them as an annotation
+ * ({@code S{db.t,v3,r1000,p1/5}}), so a user copies the state the entry was 
measured in together
+ * with the struct info, and the read side compares them with the state of the 
current query to
+ * decide whether the injected row count may still be applied (see {@link 
HboStructFreshness}).
+ *
+ * <p>None of these numbers takes part in the fingerprint:
+ * {@link GroupStructInfo#stripScanBaseline} removes the whole baseline of 
every scan token before
+ * the sha256 is taken, so a table which simply keeps growing neither changes 
the key of an entry nor
+ * makes it unmatchable; whether the entry may still be applied is a read side 
decision.
+ *
+ * <p>A number which could not be determined is omitted from the token (and 
parsed back as
+ * {@link #UNKNOWN}), e.g. the row count of a table whose row count was never 
reported by any
+ * backend: such an entry cannot be compared by rows any more and falls back 
to the version check.
+ */
+public class HboScanDescriptor {
+    /** A number which could not be determined; a row count of 0 is a known, 
meaningful value. */
+    public static final long UNKNOWN = -1;
+
+    private final String table;
+    private final long visibleVersion;
+    private final long scanRows;
+    private final RowsKind rowsKind;
+    private final long selectedPartitions;
+    private final long totalPartitions;
+
+    HboScanDescriptor(String table, long visibleVersion, long scanRows, 
RowsKind rowsKind,
+            long selectedPartitions, long totalPartitions) {
+        this.table = table;
+        this.visibleVersion = visibleVersion;
+        this.scanRows = scanRows;
+        this.rowsKind = rowsKind;
+        this.selectedPartitions = selectedPartitions;
+        this.totalPartitions = totalPartitions;
+    }
+
+    /**
+     * How the row count of a scan was obtained.
+     */
+    public enum RowsKind {
+        /**
+         * Every selected partition reported its own row count, so the number 
is the physical size of
+         * the data the scan reads.
+         */
+        MEASURED,
+        /**
+         * The row count of at least one selected partition is not available, 
so the number is
+         * derived from the table (its last reported row count, or the 
statistics of the last
+         * analysis plus the rows loaded since). Two such numbers are 
comparable with each other, but
+         * a number of this kind must never be compared with a {@link 
#MEASURED} one: the derivation
+         * can be off by a large factor, e.g. for the partitions of a skewed 
table.
+         */
+        ESTIMATED,
+        /** No row count at all (the table was neither analyzed nor reported 
by a backend). */
+        UNKNOWN
+    }
+
+    /** A row count and how it was obtained. */
+    public static class Rows {
+        private static final Rows UNKNOWN_ROWS = new Rows(UNKNOWN, 
RowsKind.UNKNOWN);
+
+        private final long rows;
+        private final RowsKind kind;
+
+        Rows(long rows, RowsKind kind) {
+            this.rows = rows;
+            this.kind = kind;
+        }
+
+        public long getRows() {
+            return rows;
+        }
+
+        public RowsKind getKind() {
+            return kind;
+        }
+
+        public boolean isKnown() {
+            return kind != RowsKind.UNKNOWN;
+        }
+    }
+
+    /** The state of a scan node: which table it reads, and how much of it. */
+    public static HboScanDescriptor of(OlapScan scan) throws RpcException {
+        OlapTable table = scan.getTable();
+        List<Long> selectedPartitionIds = scan.getSelectedPartitionIds();
+        Rows rows = rowsOf(table, selectedPartitionIds, 
scan.getSelectedIndexId());
+        return new HboScanDescriptor(table.getNameWithFullQualifiers(), 
table.getVisibleVersion(),

Review Comment:
   认可:scan key 只有库表名与基线,没有 OlapTable ID,drop + recreate 同名表会误命中。需要把 tableId 纳入 
scan 标识与 canonical。这是格式变更,本轮未做,计划下一步处理。



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to