englefly commented on code in PR #67549: URL: https://github.com/apache/doris/pull/67549#discussion_r4240221401
########## 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 -> {@code col(qualifier.name)}, + * literal -> {@code lit(value:type)} / {@code lit(*)}, other nodes -> {@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 + ")"; + } + } + + /** Equal-to, boolean and/or are commutative; everything else keeps its operand order. */ + private static boolean isOrderInsensitive(Expression expression) { + return expression instanceof EqualTo + || expression instanceof Or + || expression instanceof And; + } + + /** Traversal state; shared along the whole subtree so the visited set guards shared sub graphs. */ + /** + * The default of {@code hbo_max_scans_per_group}, used when no session is available. 20 keeps + * every TPC-DS shape observed in this repository (the largest single sub tree is query64's + * 19 scan join group) while refusing trees which are pathologically wide. + */ + public static final int DEFAULT_MAX_SCANS_PER_GROUP = 20; + + private static int hboMaxScansPerGroup() { + ConnectContext connectContext = ConnectContext.get(); + if (connectContext == null || connectContext.getSessionVariable() == null) { + return DEFAULT_MAX_SCANS_PER_GROUP; + } + return connectContext.getSessionVariable().getHboMaxScansPerGroup(); + } + + /** + * The structural summary of a memo group: how many scan tokens the struct info of its sub tree + * would contain, and which tables it reads. + * + * <p>It is aggregated bottom up from the children's summaries - no string is rendered and no + * catalog is read - and memoized per group (see {@code Group#getOrComputeHboStructSummary}), so + * it is the cheap gate in front of the canonical string: a group which is too large for + * {@code hbo_max_scans_per_group} never renders one, and a group whose relation key can not + * match any entry does not render one either (see {@code HboPlanStatisticsManager}). + */ + public static final class HboStructSummary { + private final int scanCount; + /** The tables of the sub tree, in traversal order and with duplicates (a self join twice). */ + private final List<String> relations; + private final String relationKey; + + private HboStructSummary(int scanCount, List<String> relations) { + this.scanCount = scanCount; + this.relations = Collections.unmodifiableList(new ArrayList<>(relations)); + List<String> sorted = new ArrayList<>(relations); + Collections.sort(sorted); + this.relationKey = String.join(",", sorted); + } + + /** The number of scan tokens in the sub tree (a self join counts the table twice). */ + public int getScanCount() { + return scanCount; + } + + /** + * The tables of the sub tree as one order independent key (sorted, duplicates kept). A group + * whose struct info could equal another one's necessarily shares this key, so it is a safe + * necessary condition for an entry lookup; a group with no scan at all keys to "". + */ + public String getRelationKey() { + return relationKey; + } + + List<String> getRelations() { + return relations; + } + } + + /** The summary of a group, computed from the (memoized) summaries of its child groups. */ + public static HboStructSummary summaryOf(Group group) { + GroupExpression ge = group.getFirstLogicalExpression(); Review Comment: 认可,已修复(da26dd850df):storage layer aggregate 会插入只含 physical expression 的子 group,原先 getFirstLogicalExpression() 会抛异常。现在 summaryOf 改为遍历 getLogicalExpressions() 并判空,这类 group 直接返回 INVALID,不再影响整条查询的 fingerprint 计算。 ########## fe/fe-core/src/main/java/org/apache/doris/nereids/stats/HboJoinConditions.java: ########## @@ -0,0 +1,85 @@ +// 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.trees.expressions.EqualTo; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.plans.JoinType; +import org.apache.doris.nereids.trees.plans.algebra.Join; + +import com.google.common.hash.Hashing; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Canonical description and fingerprint of the equality conditions of a join, used as the key of + * manually injected join expansion entries ({@code HBO SET EXPANSION}). + * + * <p>Only the hash (equality) conjuncts participate: the expansion behaviour of a join is a + * property of the joined column pairs, so the key deliberately excludes the join type, the shape + * of both sub trees and every non equality condition. That makes one injected entry reusable by + * every query joining the same columns. + * + * <p>The canonical form is {@code JE{EqualTo(...);EqualTo(...)}} with the pairs sorted; a pair is + * rendered by {@link GroupStructInfo#normalizeExpression(Expression)} which already sorts the two + * operands of an equality (equality is commutative), so {@code a = b} and {@code b = a} collapse. + */ +public class HboJoinConditions { + private static final String PREFIX = "JE{"; + private static final String SEP = ";"; + + private HboJoinConditions() { + } + + /** True when the injected expansion of this join type is applicable at all. */ + public static boolean isExpansionApplicable(JoinType joinType) { + // semi / anti / mark style joins can never produce more rows than their left input, and + // asof joins match at most one row per left row, so expansion can not happen there + return joinType == JoinType.INNER_JOIN + || joinType == JoinType.LEFT_OUTER_JOIN + || joinType == JoinType.RIGHT_OUTER_JOIN + || joinType == JoinType.FULL_OUTER_JOIN; + } + + /** + * Canonical string of the equality conditions, empty when the join has no equality condition + * (e.g. a cross join). + */ + public static Optional<String> canonicalOf(Join join) { + List<String> pairs = new ArrayList<>(); + for (Expression conjunct : join.getHashJoinConjuncts()) { + if (conjunct instanceof EqualTo) { Review Comment: 认可,已修复(da26dd850df):NullSafeEqual 同样是 hash conjunct(getHashJoinConjuncts 会返回),现在 <=> 与普通等值一起参与 canonical。 ########## fe/fe-core/src/main/java/org/apache/doris/nereids/stats/HboStructFreshness.java: ########## @@ -0,0 +1,585 @@ +// 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.DatabaseIf; +import org.apache.doris.catalog.Env; +import org.apache.doris.catalog.OlapTable; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.common.Config; +import org.apache.doris.datasource.CatalogIf; +import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapScan; +import org.apache.doris.rpc.RpcException; +import org.apache.doris.statistics.hbo.PlanStatistics; +import org.apache.doris.statistics.hbo.ScanPlanStatistics; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Whether a hbo entry may still be applied: the recorded data state of an entry (the baseline of + * every scan of its struct info, see {@link HboScanDescriptor}) compared with the data state of the + * query which wants to use it. + * + * <p>A hbo fingerprint does not contain any data state any more, so an entry keeps matching while a + * table grows. The read side therefore has to decide whether the injected row count still describes + * the data it was measured on: + * <ul> + * <li>{@link #STATE_LIVE}: the data the entry was measured on is still the current one, the entry + * is applied;</li> + * <li>{@link #STATE_DRIFTED}: the data moved, but by at most + * {@code Config.hbo_row_count_change_ratio} of the recorded row count (10% by default): the + * entry is applied, which is the point of the tolerance - a table which merely keeps growing + * (e.g. a few new rows in a partition) does not invalidate a measured row count;</li> + * <li>{@link #STATE_STALE}: the data moved further than that: the entry is <b>not</b> applied, and + * the query falls back to the optimizer estimation;</li> + * <li>{@link #STATE_UNKNOWN}: nothing can be compared (the entry records no row count, or the + * current one cannot be read / was obtained a different way), so there is nothing to judge: the + * entry is applied as it always was.</li> + * </ul> + * The recorded row count is never scaled: an entry either applies with the value the user measured + * or does not apply at all ("soft strategy" - a user who needs the exact recorded state in any case + * should not inject hbo statistics for that query). Setting + * {@code hbo_row_count_change_ratio} to 0 or less switches the verdict back to the strict semantics + * hbo had before the baseline existed: the recorded visible version has to still be the current one. + * + * <p>The rows are compared <b>before</b> the version, and only against a number which was obtained + * the same way ({@link HboScanDescriptor.RowsKind}): the visible version is a property of the table, + * so two different partition selections of one table share it while reading completely different + * amounts of data, and a measured row count must not be compared with one that was derived from the + * table average. Whenever nothing can be compared - a hand written struct literal, an entry whose + * rows were never measured, a current row count which cannot be read, or two numbers of different + * kinds - the state is {@link #STATE_UNKNOWN} and the entry is applied: a transient failure to read + * the catalog must not drop an entry the user injected. The version is only decisive in strict mode + * ({@code hbo_row_count_change_ratio <= 0}). + * + * <p>A scan is judged by the rows of its <b>selected</b> partitions, so pruning decides which data + * the entry depends on: an unrelated partition which grows does not invalidate a pruned entry. + * {@code HBO SHOW STATISTICS} and {@code HBO DELETE STALE STATISTICS} cannot run the query of an + * entry, so there the recorded state is compared with the table as a whole; an entry whose struct + * prunes partitions can therefore only be judged by rows when it was recorded as a whole table scan + * (its state is reported as unknown otherwise, and such an entry is kept unless the user asks for + * {@code OLDER_THAN}). + * + * <p>Learned (profile collected) entries carry the input table statistics of the run they were + * measured in, so they are judged by the same tolerance over those recorded rows; an entry injected + * by {@code HBO SET LEARNED STATISTICS} carries no such statistics and is applied as it always was. + */ +public class HboStructFreshness { + /** The recorded data state is still the current one. */ + public static final String STATE_LIVE = "live"; + /** The data moved, but within the tolerance of {@code Config.hbo_row_count_change_ratio}. */ + public static final String STATE_DRIFTED = "drifted"; + /** The data moved further than the tolerance: the entry must not be applied. */ + public static final String STATE_STALE = "stale"; + /** The entry records no data state (or it cannot be read any more): nothing to judge. */ + public static final String STATE_UNKNOWN = "unknown"; + + private static final Logger LOG = LogManager.getLogger(HboStructFreshness.class); + + private final String state; + // the recorded baseline and the current state, both rendered for display + private final String recorded; + private final String live; + // the row counts of the scan which decided the state, {@link HboScanDescriptor#UNKNOWN} when the + // state was decided by something else (e.g. a version which is not readable) + private final long recordedRows; + private final long liveRows; + + private HboStructFreshness(String state, String recorded, String live, long recordedRows, long liveRows) { + this.state = state; + this.recorded = recorded; + this.live = live; + this.recordedRows = recordedRows; + this.liveRows = liveRows; + } + + public String getState() { + return state; + } + + public boolean isStale() { + return STATE_STALE.equals(state); + } + + public boolean isUnknown() { + return STATE_UNKNOWN.equals(state); + } + + public boolean isDrifted() { + return STATE_DRIFTED.equals(state); + } + + /** The data state the entry was recorded in, e.g. {@code hbo_test.t1:v3,r1000,p1/5}. */ + public String getRecorded() { + return recorded; + } + + /** The data state the entry is compared with (empty when it cannot be read). */ + public String getLive() { + return live; + } + + /** + * The verdict as one token, printed after {@code used=} / {@code skipped=} in the explain + * annotation, e.g. {@code drifted(rows=1030,rec=1000,+3.0%)}. + */ + public String getSummary() { + if (!STATE_DRIFTED.equals(state) && !STATE_STALE.equals(state)) { + return state; + } + if (recordedRows == HboScanDescriptor.UNKNOWN || liveRows == HboScanDescriptor.UNKNOWN) { + return state; + } + return state + "(rows=" + liveRows + ",rec=" + recordedRows + "," + + changeText(recordedRows, liveRows) + ")"; + } + + /** The {@code now} part of {@code HBO SHOW STATISTICS}: the state read from the catalog. */ + public String getLiveDetail() { + if (live.isEmpty()) { + return "-"; + } + if (recordedRows == HboScanDescriptor.UNKNOWN || liveRows == HboScanDescriptor.UNKNOWN) { + return live; + } + return live + "," + changeText(recordedRows, liveRows); + } + + /** + * Whether the entry records any data state at all. An entry which does not (a struct literal + * typed by hand, or one copied from a plan whose tables were never reported) is always applied, + * so a caller does not have to compute the state of the current query to find that out. + */ + public static boolean hasRecordedDataState(String recordedCanonical) { + for (HboScanDescriptor scan : HboScanDescriptor.parseAll(recordedCanonical)) { + if (scan.hasVisibleVersion() || scan.hasScanRows()) { + return true; + } + } + return false; + } + + /** + * Judge an entry against the current query: the data state recorded in {@code recordedCanonical} + * (the struct info of the entry) against the state of {@code liveCanonical} (the struct info the + * read side just computed for the group). This is the decision of the read side. + */ + public static HboStructFreshness between(String recordedCanonical, GroupStructInfo liveStructInfo) { + return between(recordedCanonical, liveStructInfo.getScans()); + } + + /** The same comparison for a caller which has the scans of the current query only. */ + static HboStructFreshness between(String recordedCanonical, List<HboScanDescriptor> liveScans) { + List<HboScanDescriptor> recordedScans = HboScanDescriptor.parseAll(recordedCanonical); + String liveText = renderScans(liveScans); + if (recordedScans.isEmpty()) { + // an entry without a baseline (a hand written struct literal): nothing to judge + return new HboStructFreshness(STATE_UNKNOWN, "", liveText, + HboScanDescriptor.UNKNOWN, HboScanDescriptor.UNKNOWN); + } + if (recordedScans.size() != liveScans.size()) { + // the entry describes a different number of scans than the group: cannot be compared + return new HboStructFreshness(STATE_UNKNOWN, renderScans(recordedScans), liveText, + HboScanDescriptor.UNKNOWN, HboScanDescriptor.UNKNOWN); + } + String state = STATE_LIVE; + String recordedText = renderScans(recordedScans); + + long recordedRows = HboScanDescriptor.UNKNOWN; + long liveRows = HboScanDescriptor.UNKNOWN; + for (int i = 0; i < recordedScans.size(); i++) { + HboScanDescriptor recorded = recordedScans.get(i); + HboScanDescriptor live = liveScans.get(i); + String scanState; + if (!recorded.getTable().equals(live.getTable())) { + // the same fingerprint describes the same scans in the same order, so this cannot + // happen for an entry of this group: refuse to judge rather than guess + scanState = STATE_UNKNOWN; + } else { + scanState = stateOf(recorded, live); + } + if (worse(scanState, state).equals(scanState)) { + state = scanState; + recordedRows = recorded.getScanRows(); + liveRows = live.getScanRows(); + } + } + return new HboStructFreshness(state, recordedText, liveText, recordedRows, liveRows); + } + + /** + * Judge an entry against the catalog as a whole (used by {@code HBO SHOW STATISTICS} and + * {@code HBO DELETE STALE STATISTICS}, which cannot run the query of an entry): the recorded + * visible version and row count of every table against the current ones. + * + * <p>Row counts are only comparable when the scan read the whole table (no partition was pruned + * away), because only then the recorded rows are the rows of the table. + */ + public static HboStructFreshness of(String canonicalStructInfo) { + List<HboScanDescriptor> recordedScans = HboScanDescriptor.parseAll(canonicalStructInfo); + if (recordedScans.isEmpty()) { + return new HboStructFreshness(STATE_UNKNOWN, "", "", HboScanDescriptor.UNKNOWN, + HboScanDescriptor.UNKNOWN); + } + String state = STATE_LIVE; + // the current state of every table, de duplicated like the recorded side (a self join reads + // the same table twice, which would only repeat the same numbers) + Set<String> liveTexts = new LinkedHashSet<>(); + long recordedRows = HboScanDescriptor.UNKNOWN; + long liveRows = HboScanDescriptor.UNKNOWN; + for (HboScanDescriptor recorded : recordedScans) { + OlapTable table = resolveTable(recorded.getTable()); + if (table == null) { + if (worse(STATE_UNKNOWN, state).equals(STATE_UNKNOWN)) { + state = STATE_UNKNOWN; + } + liveTexts.add(recorded.getTable() + ":-"); + continue; + } + if (table.getIndexIdList().size() > 1 && !recorded.isPartitionSelectionComplete()) { + // the table has more than one index (a rollup) and the entry records only a partition + // count, so this statement cannot tell which index its row count came from: judging it + // against the base index could be wrong in both directions, so it is left to a query + liveTexts.add(recorded.getTable() + ":-"); + if (worse(STATE_UNKNOWN, state).equals(STATE_UNKNOWN)) { + state = STATE_UNKNOWN; + } + continue; + } + long currentVersion = visibleVersionOf(table); + // the rows of the whole table, computed the same way the recorded rows of a whole table + // scan were computed, so the two numbers describe the same thing + HboScanDescriptor.Rows liveRowsOfTable = HboScanDescriptor.rowsOf(table, + new ArrayList<>(table.getPartitionIds()), table.getBaseIndexId()); + long currentRows = liveRowsOfTable.getRows(); + HboScanDescriptor.RowsKind currentRowsKind = liveRowsOfTable.getKind(); + liveTexts.add(recorded.getTable() + ":v" + currentVersion + + (currentRows == HboScanDescriptor.UNKNOWN ? "" + : (currentRowsKind == HboScanDescriptor.RowsKind.MEASURED ? ",r" : ",e") + + currentRows)); + String scanState; + if (!recorded.hasVisibleVersion() && !recorded.hasScanRows()) { + // nothing was recorded for this scan: it applies as it always was, nothing to judge + scanState = STATE_UNKNOWN; + currentRows = HboScanDescriptor.UNKNOWN; + } else if (threshold() <= 0) { + if (currentVersion == HboScanDescriptor.UNKNOWN || !recorded.hasVisibleVersion()) { + // a temporary metadata failure must not look like a version mismatch: DELETE + // STALE would remove an entry which a query can still apply + scanState = STATE_UNKNOWN; + } else { + scanState = currentVersion == recorded.getVisibleVersion() ? STATE_LIVE : STATE_STALE; + } + currentRows = HboScanDescriptor.UNKNOWN; + } else if (recorded.hasVisibleVersion() && currentVersion == recorded.getVisibleVersion()) { + // the visible version is a property of the whole table, so an unchanged version means + // that nothing was loaded into any of its partitions: the recorded state is still the + // current one, whatever the entry was measured on. (The read side can not use this + // shortcut: it knows which partitions the entry was measured on and which ones the + // query reads, and those can differ under one unchanged version.) + scanState = STATE_LIVE; + } else if (!recorded.hasScanRows() || !recorded.isPartitionSelectionComplete()) { + // the entry records no row count at all (a struct literal of an older format, for + // example), or it records the rows of pruned partitions only, which are not the rows + // of the table: the state can only be judged by a query of that entry + scanState = STATE_UNKNOWN; + currentRows = HboScanDescriptor.UNKNOWN; + } else if (currentRows == HboScanDescriptor.UNKNOWN + || currentRowsKind != recorded.getRowsKind()) { + // the current row count cannot be read or was obtained differently than the recorded + // one, so the two are not comparable: the read side reports unknown for exactly this + // case and keeps applying the entry, and this scope must not disagree by calling it + // stale (DELETE STALE would then remove an entry which queries still use) + scanState = STATE_UNKNOWN; + currentRows = HboScanDescriptor.UNKNOWN; + } else if (recorded.getScanRows() == currentRows) { + scanState = STATE_LIVE; + } else { + scanState = changeRatio(recorded.getScanRows(), currentRows) <= threshold() + ? STATE_DRIFTED : STATE_STALE; + } + if (worse(scanState, state).equals(scanState) && scanState != STATE_LIVE) { + state = scanState; + recordedRows = scanState == STATE_UNKNOWN + ? HboScanDescriptor.UNKNOWN : recorded.getScanRows(); + liveRows = scanState == STATE_UNKNOWN ? HboScanDescriptor.UNKNOWN : currentRows; + } + } + return new HboStructFreshness(state, renderScans(recordedScans), String.join("; ", liveTexts), + recordedRows, liveRows); + } + + /** + * Judge a learned (profile collected) entry: the rows its input tables had when it was measured + * against the rows of the scans of the current query. The caller must only use this for an entry + * of a node whose recorded rows are the rows of the data it reads (a scan node: see + * {@code HboStatsCalculator.applyLearnedStats}). The input tables are aggregated by table name, because + * a learned entry describes the scans of the node it was published for in plan order while the + * struct info orders them canonically. + * + * <p>This is a one way guard: it only rejects an entry when a comparable pair of row counts + * proves that the data moved beyond the tolerance. Everything which cannot be compared (an + * injected entry without input statistics, a different number of tables, a row count of a + * different kind) leaves the entry applicable, so a learned lookup which worked before this + * check keeps working. + */ + public static HboStructFreshness ofLearnedEntry(List<PlanStatistics> recordedInputStatistics, + GroupStructInfo liveStructInfo) { + return ofLearnedEntry(recordedInputStatistics, liveStructInfo.getScans()); + } + + /** The same guard for a caller which has the scans of the current query only. */ + static HboStructFreshness ofLearnedEntry(List<PlanStatistics> recordedInputStatistics, + List<HboScanDescriptor> liveScans) { + Map<String, Long> recordedRowsByTable = new LinkedHashMap<>(); + if (recordedInputStatistics != null) { + for (PlanStatistics input : recordedInputStatistics) { + if (!(input instanceof ScanPlanStatistics)) { + continue; + } + PhysicalOlapScan scan = ((ScanPlanStatistics) input).getScan(); + if (scan == null || scan.getTable() == null) { + continue; + } + String table = scan.getTable().getNameWithFullQualifiers(); + // the input rows of a scan node are the rows it read, i.e. the number a catalog row + // count (which also ignores the predicates) can be compared with + recordedRowsByTable.merge(table, input.getInputRows(), Long::sum); + } + } + if (recordedRowsByTable.isEmpty()) { + // an injected learned entry carries no input table statistics: nothing to compare + return new HboStructFreshness(STATE_UNKNOWN, "", renderScans(liveScans), + HboScanDescriptor.UNKNOWN, HboScanDescriptor.UNKNOWN); + } + return ofLearnedRows(recordedRowsByTable, liveScans); + } + + /** + * The comparison of a learned entry: the rows it recorded for each input table against the rows + * of the scans of the current query. Only measured row counts are comparable with the recorded + * ones (a learned number is a measurement of a real run), and a table whose rows cannot be + * compared leaves the entry applicable. + */ + static HboStructFreshness ofLearnedRows(Map<String, Long> recordedRowsByTable, + List<HboScanDescriptor> liveScans) { + Map<String, long[]> liveByTable = new LinkedHashMap<>(); + Map<String, HboScanDescriptor.RowsKind> liveKinds = new LinkedHashMap<>(); + for (HboScanDescriptor scan : liveScans) { + long[] rows = liveByTable.computeIfAbsent(scan.getTable(), key -> new long[] {0}); + if (scan.hasScanRows()) { + rows[0] += scan.getScanRows(); + } else { + rows[0] = HboScanDescriptor.UNKNOWN; + } + HboScanDescriptor.RowsKind kind = liveKinds.get(scan.getTable()); + // a table which is scanned twice is only comparable when both scans agree on the kind + liveKinds.put(scan.getTable(), kind == null || kind == scan.getRowsKind() + ? scan.getRowsKind() : HboScanDescriptor.RowsKind.UNKNOWN); + } + String state = STATE_LIVE; + boolean compared = false; + long recordedRows = HboScanDescriptor.UNKNOWN; + long liveRows = HboScanDescriptor.UNKNOWN; + for (Map.Entry<String, Long> entry : recordedRowsByTable.entrySet()) { + long[] live = liveByTable.get(entry.getKey()); + long recorded = entry.getValue(); + if (live == null || recorded == HboScanDescriptor.UNKNOWN + || live[0] == HboScanDescriptor.UNKNOWN + || liveKinds.get(entry.getKey()) != HboScanDescriptor.RowsKind.MEASURED) { + // the learned numbers are measured row counts, so they are only compared with a + // measured one: a derived number could be off by a large factor + continue; + } + compared = true; + String tableState = recorded == live[0] ? STATE_LIVE + : changeRatio(recorded, live[0]) <= threshold() ? STATE_DRIFTED : STATE_STALE; + if (worse(tableState, state).equals(tableState)) { + state = tableState; + recordedRows = recorded; + liveRows = live[0]; + } + } + if (!compared) { + // nothing could be compared, so the entry is applied as it always was + return new HboStructFreshness(STATE_UNKNOWN, renderTables(recordedRowsByTable), + renderTables(liveByTable), HboScanDescriptor.UNKNOWN, HboScanDescriptor.UNKNOWN); + } + return new HboStructFreshness(state, renderTables(recordedRowsByTable), renderTables(liveByTable), + recordedRows, liveRows); + } + + /** Render the aggregated row counts of a learned entry, e.g. {@code hbo_test.t1:r1000}. */ + private static String renderTables(Map<String, ? extends Object> rowsByTable) { + List<String> rendered = new ArrayList<>(); + for (Map.Entry<String, ? extends Object> entry : rowsByTable.entrySet()) { + long rows = entry.getValue() instanceof long[] ? ((long[]) entry.getValue())[0] + : (Long) entry.getValue(); + // a learned number is a measurement of a real run, so it is rendered as one + rendered.add(entry.getKey() + (rows == HboScanDescriptor.UNKNOWN ? "" : ":r" + rows)); + } + return String.join("; ", rendered); + } + + /** + * The state of one scan of the current query against the same scan of an entry. + * See the class comment for the meaning of the states. + */ + private static String stateOf(HboScanDescriptor recorded, HboScanDescriptor live) { + if (!recorded.hasVisibleVersion() && !recorded.hasScanRows()) { + // the entry records nothing about this scan (e.g. a struct literal typed by hand) + return STATE_UNKNOWN; + } + if (threshold() <= 0) { + // strict mode: only the recorded data state may be reused - the semantics hbo had before + // the baseline existed, when the visible version was part of the key + return recorded.hasSameVersion(live) ? STATE_LIVE : STATE_STALE; + } + if (recorded.hasScanRows() && live.hasScanRows()) { + if (recorded.getRowsKind() != live.getRowsKind()) { + // the two numbers were obtained differently (one is a measurement of the partitions + // the scan selected, the other one is derived from the whole table), so they are not + // comparable: comparing them would reject entries whose data did not change at all, + // and the version alone can not say whether the two describe the same data (the same + // version covers every partition of the table). The state is therefore reported as + // unknown and the entry is applied as it always was. + return STATE_UNKNOWN; + } + // the rows are compared BEFORE the version: a different partition selection (or another + // scan of the same table) can read a completely different amount of data under the very + // same table version, so equal versions do not mean that the entry still fits + if (recorded.getScanRows() == live.getScanRows()) { + return STATE_LIVE; + } + return changeRatio(recorded.getScanRows(), live.getScanRows()) <= threshold() + ? STATE_DRIFTED : STATE_STALE; + } + // the recorded baseline was never a measurement, or the current row count cannot be read: + // there is nothing to judge, so the entry is applied as it always was + return STATE_UNKNOWN; + } + + /** How much the row count of a scan may change and still be reused; a non-positive value is strict. */ + private static double threshold() { + return Config.hbo_row_count_change_ratio; + } + + /** {@code |now - recorded| / recorded}; 1 when the recorded side is empty and the other is not. */ + private static double changeRatio(long recordedRows, long liveRows) { + if (recordedRows == 0) { + return liveRows == 0 ? 0 : 1; + } + return Math.abs(liveRows - recordedRows) / (double) recordedRows; + } + + /** {@code +3.0%} / {@code -20.0%}: how the current row count differs from the recorded one. */ + private static String changeText(long recordedRows, long liveRows) { + double change = recordedRows == 0 + ? (liveRows == 0 ? 0 : 1) : (liveRows - recordedRows) / (double) recordedRows; + return String.format(Locale.ROOT, "%+.1f%%", change * 100); + } + + /** The worse of two states (live, drifted, unknown, stale - in this order). */ + private static String worse(String left, String right) { + return severity(left) >= severity(right) ? left : right; + } + + private static int severity(String state) { + switch (state) { + case STATE_STALE: + return 3; + case STATE_UNKNOWN: + return 2; + case STATE_DRIFTED: + return 1; + default: + return 0; + } + } + + /** Render the baseline of a scan, e.g. {@code hbo_test.t1:v3,r1000,p1/5}. */ + private static String renderScans(List<HboScanDescriptor> scans) { + Set<String> rendered = new LinkedHashSet<>(); + for (HboScanDescriptor scan : scans) { + StringBuilder sb = new StringBuilder(scan.getTable()); + if (scan.hasVisibleVersion()) { + sb.append(":v").append(scan.getVisibleVersion()); + } + if (scan.hasScanRows()) { + sb.append(scan.getRowsKind() == HboScanDescriptor.RowsKind.MEASURED ? ",r" : ",e") + .append(scan.getScanRows()); + } + if (scan.getTotalPartitions() != HboScanDescriptor.UNKNOWN + && !scan.isPartitionSelectionComplete()) { + sb.append(",p").append(scan.getSelectedPartitions()).append('/') + .append(scan.getTotalPartitions()); + } + rendered.add(sb.toString()); + } + return String.join("; ", rendered); + } + + /** The current visible version of a table, or {@link HboScanDescriptor#UNKNOWN}. */ + private static long visibleVersionOf(OlapTable table) { + try { + return table.getVisibleVersion(); + } catch (RpcException | RuntimeException e) { + LOG.debug("failed to read the visible version of {}", table.getName(), e); + return HboScanDescriptor.UNKNOWN; + } + } + + /** Resolve {@code [catalog.]db.table} to the olap table, or null when it does not exist. */ + private static OlapTable resolveTable(String fullName) { + String[] parts = fullName.split("\\."); + try { + if (parts.length < 2 || parts.length > 3) { Review Comment: 认可,已修复(da26dd850df):resolveTable 不再固定按 3 段切分,改为依次尝试可能的 catalog / database 组合(长的 database 名优先),nested namespace 下 internal.tenant.analytics.t 可以正确解析。 ########## fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java: ########## @@ -7182,6 +7185,146 @@ public LogicalPlan visitAdminSetFrontendConfig(DorisParser.AdminSetFrontendConfi applyToAll); } + @Override + public LogicalPlan visitHboSetStatistics(DorisParser.HboSetStatisticsContext ctx) { + checkHboStatementWords(ctx.hbo, ctx.statistics); + Double value = null; + String typeName = null; + String fingerprint = null; + String structCanonical = null; + String literalModeName = null; + for (DorisParser.HboSetParamContext param : ctx.hboSetParam()) { + if (param instanceof DorisParser.HboSetValueContext) { + if (value != null) { + throw new ParseException("VALUE is given twice in hbo set statistics statement"); + } + try { + value = Double.parseDouble(((DorisParser.HboSetValueContext) param).value.getText()); + } catch (NumberFormatException e) { + throw new ParseException("hbo statistics VALUE out of range: " + + ((DorisParser.HboSetValueContext) param).value.getText()); + } + } else if (param instanceof DorisParser.HboSetTypeContext) { + if (typeName != null) { + throw new ParseException("TYPE is given twice in hbo set statistics statement"); + } + typeName = ((DorisParser.HboSetTypeContext) param).typeName.getText(); + } else if (param instanceof DorisParser.HboSetStructContext) { + if (structCanonical != null) { + throw new ParseException("STRUCT is given twice in hbo set statistics statement"); + } + structCanonical = stripQuotes(((DorisParser.HboSetStructContext) param).structCanonical.getText()); Review Comment: 认可,已修复(da26dd850df):STRUCT='...' 是 STRING_LITERAL,改为走 SqlLiteralUtils.parseStringLiteral(),与其它字符串字面量一致的转义解码。 ########## fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/HboStatisticsCommand.java: ########## @@ -0,0 +1,330 @@ +// 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.trees.plans.commands; + +import org.apache.doris.catalog.Env; +import org.apache.doris.common.AnalysisException; +import org.apache.doris.mysql.privilege.PrivPredicate; +import org.apache.doris.nereids.stats.GroupStructInfo; +import org.apache.doris.nereids.stats.HboPlanStatisticsManager; +import org.apache.doris.nereids.stats.HboPlanStatisticsManager.LiteralMode; +import org.apache.doris.nereids.stats.HboPlanStatisticsManager.PinnedType; +import org.apache.doris.nereids.stats.HboStatisticsStore; +import org.apache.doris.nereids.trees.plans.PlanType; +import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.StmtExecutor; + +import com.google.common.hash.Hashing; + +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Manual HBO statistics management statements: + * <pre> + * HBO SET [PINNED|LEARNED] STATISTICS VALUE=<rows> [TYPE=<type>] [STRUCT='<canonical>'] + * FINGERPRINT='<fingerprint>' [LITERAL_MODE=<mode>] + * HBO DELETE [PINNED|LEARNED] STATISTICS FINGERPRINT='<fingerprint>' + * </pre> + * The optional scope selects which cache is written: {@code PINNED} (default) injects an entry that + * is authoritative over the learned entries, {@code LEARNED} injects into the learned cache so the + * learned lookup path can be exercised without a real profile publish. The injected value is bound + * to the fingerprint the user copied from EXPLAIN; which struct info that fingerprint belongs to is + * decided here (an explicit {@code LITERAL_MODE} wins, the default is the constant agnostic form + * every join / aggregation key uses) and persisted with the entry. + * + * <p>The struct info is not just a label: it carries the data state of every scan it reads + * ({@code S{db.t,v3,r1000,p1/5}}, see {@link org.apache.doris.nereids.stats.HboScanDescriptor}), + * which the read side compares with + * the data state of the query before applying the entry + * (see {@link org.apache.doris.nereids.stats.HboStructFreshness}). Copying + * the {@code struct=} value of EXPLAIN therefore also records what the injected value was measured + * on. + */ +public class HboStatisticsCommand extends Command { + + /** Operation kind. */ + public enum Op { + SET, + DELETE + } + + /** Which cache the statement targets. */ + public enum Scope { + PINNED, + LEARNED + } + + private static final Pattern FINGERPRINT_PATTERN = Pattern.compile("[0-9a-fA-F]{64}"); + + private final Op op; + private final Scope scope; + private final String fingerprint; + // a row count, or the fan-out factor of a PinnedType.JOIN_EXPANSION entry + private final double value; + private final PinnedType type; + private final String structCanonical; + // which struct info the entry is keyed by (WITH_LITERAL / NO_LITERAL), null means "derive" + private final String literalModeName; + + /** + * HboStatisticsCommand + * @param op SET or DELETE + * @param scopeName optional PINNED / LEARNED, null or empty means PINNED + * @param fingerprint hbo fingerprint (sha256 of the simplified group struct info) + * @param value injected output row count, or the fan-out factor for JOIN_EXPANSION + * @param typeName optional EXACT / FILTER_SMALL, null means EXACT + * @param structCanonical optional human-readable simplified struct info canonical string + * @param literalModeName optional WITH_LITERAL / NO_LITERAL, null means the constant agnostic + * (NO_LITERAL) form, which is also what the struct is folded to + */ + public HboStatisticsCommand(Op op, String scopeName, String fingerprint, double value, + String typeName, String structCanonical, String literalModeName) { + super(PlanType.HBO_STATISTICS_COMMAND); + this.op = op; + this.scope = parseScope(scopeName); + // sha256 hex fingerprints are lowercase everywhere (group struct info, read-side lookup); + // normalize user input so an uppercase fingerprint cannot silently miss its entry + this.fingerprint = fingerprint == null ? null : fingerprint.toLowerCase(Locale.ROOT); + this.value = value; + this.type = typeName == null ? PinnedType.EXACT : PinnedType.fromName(typeName); + this.structCanonical = structCanonical == null ? "" : structCanonical; + this.literalModeName = literalModeName; + } + + @Override + public void run(ConnectContext ctx, StmtExecutor executor) throws Exception { + if (!Env.getCurrentEnv().getAccessManager() + .checkGlobalPriv(ConnectContext.get(), PrivPredicate.ADMIN)) { + throw new AnalysisException("Access denied: HBO statistics management requires ADMIN privilege"); + } + HboPlanStatisticsManager hboManager = Env.getCurrentEnv().getHboPlanStatisticsManager(); + // validate the fingerprint shape on both operations: a malformed DELETE would otherwise + // silently leave the pinned entry active (it can never match a stored fingerprint) + validateFingerprint(fingerprint); + if (type == null) { + throw new AnalysisException( + "invalid hbo statistics type, expect EXACT, FILTER_SMALL or JOIN_EXPANSION"); + } + if (scope == Scope.LEARNED && type != PinnedType.EXACT) { + // a learned entry only carries the row count; the other types are pinned-only concepts + throw new AnalysisException("TYPE is not supported for hbo learned statistics"); + } + if (scope == Scope.LEARNED && literalModeName != null) { + throw new AnalysisException("LITERAL_MODE is not supported for hbo learned statistics"); + } + if (op == Op.SET) { + // a learned entry has no key of its own (it is looked up by the fingerprint the read side + // computes), so its struct info is a label only: it is validated as pasted instead of + // being folded to the constant agnostic form + LiteralMode literalMode = scope == Scope.LEARNED ? LiteralMode.WITH_LITERAL + : resolveLiteralMode(literalModeName); + // a pinned entry is only displayable when it carries its struct info, and a struct info + // which does not belong to the fingerprint would make the SHOW output misleading + String canonical = validateStructCanonical(fingerprint, structCanonical, type, literalMode, + scope != Scope.LEARNED, scope != Scope.LEARNED); + if (!HboStatisticsStore.fitsStructColumn(canonical)) { + throw new AnalysisException("hbo statistics STRUCT is too long to be persisted without" + + " truncation (the scan baseline would be cut off), shorten the node it was" + + " copied from"); + } + if (type == PinnedType.JOIN_EXPANSION) { + // a fan-out factor relative to the larger input of the join: >= 1 means the join + // expands, < 1 means it filters (0.1 keeps 10% of that input), so only zero and + // negative values are meaningless + if (value <= 0) { + throw new AnalysisException( + "hbo join expansion must be greater than 0: " + value); + } + } else if (value < 0 || value != Math.floor(value) || value >= (double) Long.MAX_VALUE) { + // the VALUE of every other type is a row count; a double above Long.MAX_VALUE would + // not be rejected by the integer check but would silently saturate when it is cast + // (and persisted) as a long + throw new AnalysisException("hbo statistics VALUE must be a non-negative integer" + + " no greater than " + Long.MAX_VALUE + " for type " + type + ": " + value); + } + if (scope == Scope.LEARNED) { + hboManager.putLearnedPlanStatistics(fingerprint, (long) value, structCanonical); + } else if (type == PinnedType.JOIN_EXPANSION) { + hboManager.putPinnedExpansionStatistics(fingerprint, value, canonical); + } else { + hboManager.putPinnedPlanStatistics(fingerprint, (long) value, type, canonical, literalMode); + } + return; + } + switch (op) { + case DELETE: + if (scope == Scope.LEARNED) { + hboManager.removeLearnedPlanStatistics(fingerprint); + } else { + hboManager.removePinnedPlanStatistics(fingerprint); + } + break; + default: + throw new IllegalStateException("unexpected hbo statistics op: " + op); + } + } + + /** + * Which struct info the entry is keyed by. An explicit LITERAL_MODE wins (the pasted struct is + * folded accordingly); the default is the constant agnostic form, because that is the key the + * join / aggregation read paths use and it makes a filter injection survive a constant change. + */ + private static LiteralMode resolveLiteralMode(String modeName) throws AnalysisException { + if (modeName == null) { + return LiteralMode.NO_LITERAL; + } + for (LiteralMode mode : LiteralMode.values()) { + if (mode.name().equalsIgnoreCase(modeName)) { + return mode; + } + } + throw new AnalysisException( + "invalid hbo statistics LITERAL_MODE, expect WITH_LITERAL or NO_LITERAL: " + modeName); + } + + /** The canonical the entry is keyed by: the pasted struct, folded for the agnostic mode. */ + private static String canonicalFor(String structCanonical, LiteralMode literalMode) { + return literalMode == LiteralMode.NO_LITERAL + ? GroupStructInfo.toNoLiteral(structCanonical) : structCanonical; + } + + @Override + public <R, C> R accept(PlanVisitor<R, C> visitor, C context) { + return visitor.visitCommand(this, context); + } + + public Op getOp() { + return op; + } + + public Scope getScope() { + return scope; + } + + public String getFingerprint() { + return fingerprint; + } + + public long getRows() { + return (long) value; + } + + /** The row count, or the fan-out factor of a JOIN_EXPANSION entry. */ + public double getValue() { + return value; + } + + public PinnedType getPinnedType() { + return type; + } + + public String getStructCanonical() { + return structCanonical; + } + + private static Scope parseScope(String scopeName) { + if (scopeName == null || scopeName.isEmpty() || "pinned".equalsIgnoreCase(scopeName)) { + return Scope.PINNED; + } + if ("learned".equalsIgnoreCase(scopeName)) { + return Scope.LEARNED; + } + throw new IllegalArgumentException( + "invalid hbo statistics scope, expect PINNED or LEARNED: " + scopeName); + } + + /** + * Check that the pasted struct info describes the sub tree the fingerprint was taken from. The + * canonical form is accepted as it is printed by EXPLAIN, and also with every literal replaced + * by {@code lit(*)} (see {@link GroupStructInfo#toNoLiteral}) because the constant agnostic + * fingerprint of a filter node is looked up with exactly that struct info. + * + * @param required whether a missing struct info is an error (pinned entries are displayed by + * {@code HBO SHOW STATISTICS}, so they must carry one) + * @param keyedEntry whether the struct info is the key of the entry. A pinned entry is looked up + * by the sha256 of what the user pasted, so the struct info has to be a form + * the read side can produce; the struct info of a learned entry is a label + * only (its key is generated internally), so it is accepted as it is - which + * is also why a learned join / aggregation entry may carry its J-shape or + * A-shape struct even though no literal carrying form of those exists. + */ + private String validateStructCanonical(String targetFingerprint, String structCanonical, PinnedType type, + LiteralMode literalMode, boolean required, boolean keyedEntry) throws AnalysisException { + if (structCanonical.isEmpty()) { + if (required) { + throw new AnalysisException("hbo statistics STRUCT is required," + + " copy the struct= value of the target node from EXPLAIN"); + } + return ""; + } + // the type decides which struct info the entry can be keyed by at all + if (type == PinnedType.JOIN_EXPANSION && !structCanonical.startsWith("JE{")) { + throw new AnalysisException("TYPE=JOIN_EXPANSION is keyed by the join conditions," + + " its STRUCT must be the condition canonical: STRUCT='JE{...}'"); + } + if (type == PinnedType.FILTER_SMALL && !structCanonical.startsWith("F{")) { + throw new AnalysisException("TYPE=FILTER_SMALL can only guard a filter entry," + + " its STRUCT must be a filter root: STRUCT='F{...}(...)'"); + } + if (keyedEntry && type != PinnedType.JOIN_EXPANSION && structCanonical.startsWith("JE{")) { + // the row count of a join is pinned by the struct info of its group (J{...}); a + // condition canonical is only read by the expansion path, so such an entry could never + // be applied - report it instead of storing an entry which silently does nothing + throw new AnalysisException("a join row count entry is keyed by the group struct info," + + " its STRUCT must be the group canonical: STRUCT='J{...}(...)'. A JE{...}" + + " condition canonical is only read by TYPE=JOIN_EXPANSION"); + } + if (keyedEntry && type != PinnedType.JOIN_EXPANSION && literalMode == LiteralMode.WITH_LITERAL + && (structCanonical.startsWith("J{") || structCanonical.startsWith("A{"))) { + throw new AnalysisException("a join / aggregation entry has no literal carrying form" + + " (their canonical is always constant agnostic), use LITERAL_MODE=NO_LITERAL"); + } + if (keyedEntry && type == PinnedType.JOIN_EXPANSION && literalMode == LiteralMode.WITH_LITERAL) { + throw new AnalysisException("a join expansion entry is keyed by the constant agnostic" + + " condition canonical, use LITERAL_MODE=NO_LITERAL"); + } + String canonical = canonicalFor(structCanonical, literalMode); Review Comment: 认可,已修复(da26dd850df):pinned 行数只在 filter / join / aggregate 节点查找,S{...} 作为 key 的 pinned 条目永远查不到,现在直接在 SET 校验阶段报错,避免存进去却静默不生效。 ########## fe/fe-core/src/main/java/org/apache/doris/nereids/stats/HboUtils.java: ########## @@ -502,27 +477,56 @@ private static InputTableStatisticsInfo buildHboInputTableStatisticsInfo(Physica * @param planToIdMap planToIdMap * @param scanToFilterMap scanToFilterMap * @param curPlanNodeRuntimeStats curPlanNodeRuntimeStats + * @param nodeFingerprints snapshot of nereids plan node id -> hbo fingerprint taken during + * planning (only populated when the struct-info fingerprint is enabled) * @return plan statistics map */ public static Map<PlanNodeAndHash, PlanStatisticsWithInputInfo> genPlanStatisticsMap( Map<Integer, PhysicalPlan> idToPlanMap, Map<PhysicalPlan, Integer> planToIdMap, Map<RelationId, Set<Expression>> scanToFilterMap, - List<TPlanNodeRuntimeStatsItem> curPlanNodeRuntimeStats) { + List<TPlanNodeRuntimeStatsItem> curPlanNodeRuntimeStats, + Map<Integer, String> nodeFingerprints) { Map<PlanNodeAndHash, PlanStatisticsWithInputInfo> outputPlanStatisticsMap = new HashMap<>(); + int missingFingerprints = 0; + int skippedWithoutSnapshot = 0; for (TPlanNodeRuntimeStatsItem nodeStats : curPlanNodeRuntimeStats) { int nodeId = nodeStats.node_id; PhysicalPlan planNode = idToPlanMap.get(nodeId); - if (planNode != null) { - PlanStatistics curPlanStatistics = PlanStatistics.buildFromStatsItem( - nodeStats, planNode, scanToFilterMap); - InputTableStatisticsInfo inputTableStatisticsInfo = buildHboInputTableStatisticsInfo( - planNode, planToIdMap, scanToFilterMap, curPlanNodeRuntimeStats); - Optional<String> hash = inputTableStatisticsInfo.getHash(); - PlanNodeAndHash planNodeAndHash = new PlanNodeAndHash((AbstractPlan) planNode, hash); - PlanStatisticsWithInputInfo planHashWithInputInfo = new PlanStatisticsWithInputInfo( - nodeId, curPlanStatistics, inputTableStatisticsInfo); - outputPlanStatisticsMap.put(planNodeAndHash, planHashWithInputInfo); + if (planNode == null) { + continue; + } + PlanStatistics curPlanStatistics = PlanStatistics.buildFromStatsItem( + nodeStats, planNode, scanToFilterMap); + InputTableStatisticsInfo inputTableStatisticsInfo = buildHboInputTableStatisticsInfo( + planNode, planToIdMap, scanToFilterMap, curPlanNodeRuntimeStats); + Optional<String> hash; + if (nodeFingerprints != null && nodeFingerprints.containsKey(nodeId)) { + hash = Optional.ofNullable(nodeFingerprints.get(nodeId)); + } else { + // no planning-time snapshot for this node: skip it + skippedWithoutSnapshot++; + if (nodeFingerprints != null && !nodeFingerprints.isEmpty()) { + missingFingerprints++; + } + continue; } + if (!hash.isPresent()) { + continue; + } + PlanNodeAndHash planNodeAndHash = new PlanNodeAndHash((AbstractPlan) planNode, hash); + PlanStatisticsWithInputInfo planHashWithInputInfo = new PlanStatisticsWithInputInfo( + nodeId, curPlanStatistics, inputTableStatisticsInfo); + outputPlanStatisticsMap.put(planNodeAndHash, planHashWithInputInfo); + } + if (missingFingerprints > 0) { + LOG.warn("hbo struct fingerprint snapshot missing for {} of {} plan nodes", Review Comment: 认可,已修复(da26dd850df):规划期快照现在会为被有意排除的节点(如 sort/topn,其 group 不受支持)记录 null fingerprint,发布路径只对快照里完全没有的节点告警。 -- 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]
