HboPlanStatisticsManager.java

// 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.common.Config;
import org.apache.doris.common.FeConstants;
import org.apache.doris.common.util.TimeUtils;
import org.apache.doris.statistics.hbo.PlanStatistics;
import org.apache.doris.statistics.hbo.RecentRunsPlanStatistics;
import org.apache.doris.statistics.hbo.RecentRunsPlanStatisticsEntry;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.google.common.collect.Lists;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Global service for hbo plan stats. manager, including:
 * - HboPlanStatisticsProvider instance: hbo plan stats. cache (learned entries)
 * - HboPlanInfoProvider instance: plan info for runtime stats. identification
 * - pinned hbo statistics: manually injected statistics via the {@code HBO SET STATISTICS}
 *   statement, keyed by the hbo fingerprint; pinned entries take precedence over learned ones
 *   and are never overwritten or evicted by the automatic profile-based collection.
 *
 * <p>Pinned statistics are per-FE in memory; when
 * {@code Config.hbo_persist_pinned_to_internal_db} is enabled they are additionally written
 * through to {@code __internal_schema.hbo_statistics} and each FE loads the table into memory
 * once, in the background ({@link HboPinnedStatisticsLoader}, started by {@code Env}), so that the
 * planning path never runs a query of its own: the internal statement a load issues would be
 * planned on the calling thread, and its own plan consults this manager - a read path which
 * triggered the load could therefore re-enter the load while a SET/DELETE is in between its memory
 * update and its DB write. The in-memory cache stays authoritative: loaded entries are merged
 * with putIfAbsent, so entries injected while persistence was off (or concurrently) win over the
 * stored snapshot, and a DELETE whose DB removal failed while the load was still pending is
 * tombstoned so the pending load cannot resurrect it. Once the load succeeds, rows that were
 * actually seen in the loaded snapshot and suppressed by a tombstone are best-effort removed
 * from the table again, so they do not reappear after this FE restarts (unless that removal
 * fails too); a tombstoned row that the load did not see (e.g. hidden by replica read lag) is
 * not re-removed and may reappear after a FE restart. A FE never refreshes entries SET by
 * another FE during its lifetime: under this per-FE weak consistency model a tombstone can
 * therefore also suppress (and, if it was part of the loaded snapshot, remove) a row that
 * another FE re-created before this FE's snapshot, while a row created after the snapshot
 * survives until this FE restarts and reloads.
 */
public class HboPlanStatisticsManager {
    /** How often the background loader retries a load which could not run (schema not ready yet). */
    static final long LOAD_RETRY_INTERVAL_MS = 30_000L;

    private static final Logger LOG = LogManager.getLogger(HboPlanStatisticsManager.class);

    /**
     * How a manually injected (pinned) row count is applied.
     */
    public enum PinnedType {
        /** unconditional override once the fingerprint matches. */
        EXACT,
        /**
         * only applied while the optimizer's own filter estimate is in the pathological
         * "extremely small" regime (see {@code Config.hbo_filter_small_ratio}), so a stale
         * injection can not override a healthy estimate.
         */
        FILTER_SMALL,
        /**
         * Not a row count but a fan-out factor relative to the <b>larger</b> of the two inputs of
         * one join ({@code output rows / max(left rows, right rows)}: 0.1 keeps 10% of the larger
         * input, 1000 fans out to 1000 times it, see
         * {@code HboStatsCalculator.applyPinnedJoinExpansion}). The factor is kept in
         * {@link PinnedHboStatistics#getExpansion()} and the key is the fingerprint of the join
         * equality conditions (see {@link HboJoinConditions}), not the fingerprint of a sub tree.
         * Injected with {@code HBO SET STATISTICS ... TYPE=JOIN_EXPANSION}; it is stored in the same
         * internal table and survives a FE restart like any other pinned entry.
         */
        JOIN_EXPANSION;

        /** Parse a user supplied type name, returns null when unknown. */
        public static PinnedType fromName(String name) {
            if (name == null) {
                return null;
            }
            for (PinnedType type : values()) {
                if (type.name().equalsIgnoreCase(name)) {
                    return type;
                }
            }
            return null;
        }
    }

    /**
     * Which struct info an entry is keyed by: {@link #WITH_LITERAL} carries the literal values
     * ({@code lit(1:INT)}, only that constant matches), {@link #NO_LITERAL} has every literal
     * replaced by {@code lit(*)} (every constant of the same predicate shape matches). It is
     * decided when the entry is injected (see {@code HBO SET STATISTICS ... LITERAL_MODE}) and
     * persisted, so it is known without ever applying the entry.
     */
    public enum LiteralMode {
        WITH_LITERAL,
        NO_LITERAL
    }

    private HboPlanStatisticsProvider hboPlanStatisticsProvider;
    // the background loader of the persisted pinned entries (started by Env, see
    // startPinnedStatisticsLoader); it is the only caller of ensurePinnedLoaded
    private HboPinnedStatisticsLoader loader;
    private HboPlanInfoProvider hboPlanInfoProvider;
    // LRU bound follows the sibling hbo caches (see HboPlanInfoProvider): a non-positive
    // hbo_pinned_stats_cache_num disables the bound instead of silently disabling injection
    private final Cache<String, PinnedHboStatistics> pinnedPlanStatistics =
            (Config.hbo_pinned_stats_cache_num > 0
                    ? Caffeine.newBuilder().maximumSize(Config.hbo_pinned_stats_cache_num)
                    : Caffeine.newBuilder())
                    .build();
    // whether pinned statistics have been loaded from the internal table (only when persistence
    // is enabled); planner/command threads access it concurrently
    private volatile boolean hboPinnedLoaded = false;
    // canonical struct info of the learned entries which were injected together with a struct
    // literal (HBO SET LEARNED STATISTICS ... STRUCT '...'): a learned key is generated internally,
    // so this is the only way HBO SHOW STATISTICS can print (and match) the struct info of a
    // learned entry. Display only, bounded like the sibling caches, empty for learned entries
    // injected by fingerprint alone.
    private final Cache<String, String> learnedStructCanonical =
            (Config.hbo_pinned_stats_cache_num > 0
                    ? Caffeine.newBuilder().maximumSize(Config.hbo_pinned_stats_cache_num)
                    : Caffeine.newBuilder())
                    .build();
    // serializes the memory phases of the lazy load, SET and DELETE against each other (including
    // the best-effort persistence SQL of DELETE, so no load can observe a row in between a
    // DELETE's memory invalidation and its DB removal)
    private final Object pinnedLoadLock = new Object();
    // the relation keys (see GroupStructInfo.HboStructSummary) of the pinned row count entries, so
    // the read side can skip the expensive fingerprint of a group whose sub tree reads other tables
    // than any entry does. Only appended to (an evicted entry may keep its key): an over
    // approximation can only make a lookup happen, never suppress one.
    private final Set<String> pinnedRelationKeys = ConcurrentHashMap.newKeySet();
    // entries whose struct info records no scan at all (a hand written literal): they can not be
    // classified by a relation key, so they disable the pre filter while they exist
    private final AtomicInteger pinnedEntriesWithoutRelationKey = new AtomicInteger();
    // backoff timestamp after a failed load attempt, so a not-ready internal schema does not
    // trigger a DDL+SELECT retry for every group of every query (guarded by pinnedLoadLock)
    private long lastLoadFailedMs = 0L;
    // fingerprints whose DELETE could not be removed from the internal table while a lazy load
    // is still pending (hboPinnedLoaded false); the pending or retrying load must not resurrect
    // them into memory. Cleared after a successful load, at which point the rows actually seen
    // in the loaded snapshot are best-effort removed from the table again; a tombstoned row the
    // load did not see (e.g. replica read lag) is not re-removed and may reappear after a FE
    // restart. Under the per-FE weak consistency model a tombstone can also suppress/remove a
    // row that another FE re-created before this FE's snapshot (see class javadoc).
    // (guarded by pinnedLoadLock)
    private final Set<String> pendingLoadTombstones = new HashSet<>();

    public HboPlanStatisticsManager() {
        hboPlanStatisticsProvider = new MemoryHboPlanStatisticsProvider();
        hboPlanInfoProvider = new HboPlanInfoProvider();
    }

    public HboPlanStatisticsProvider getHboPlanStatisticsProvider() {
        return hboPlanStatisticsProvider;
    }

    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)) {
                        failed++;
                    }
                }
                if (failed > 0) {
                    LOG.warn("failed to remove {} of {} tombstoned hbo pinned statistics rows; "
                            + "the rows will reappear after the next FE restart", failed,
                            suppressedByTombstone.size());
                }
            }
            int tombstonesNotSeen = pendingLoadTombstones.size() - suppressedByTombstone.size();
            if (tombstonesNotSeen > 0) {
                // mostly benign (the row is already gone), but under replica read lag the row may
                // still be stored without this FE knowing; it then reappears after a FE restart
                LOG.info("{} tombstoned hbo pinned statistics rows were not seen in the loaded "
                        + "snapshot and may still be stored in the table; they may reappear after "
                        + "the next FE restart", tombstonesNotSeen);
            }
            pendingLoadTombstones.clear();
            LOG.info("loaded {} hbo pinned statistics entries from internal table", loaded.size());
        }
    }

    private static boolean persistenceEnabled() {
        return Config.hbo_persist_pinned_to_internal_db && FeConstants.enableInternalSchemaDb;
    }

    /**
     * A manually injected hbo statistics entry.
     */
    public static class PinnedHboStatistics {
        private final String fingerprint;
        private final long rows;
        private final PinnedType type;
        private final String structCanonical;
        // fan-out factor of a PinnedType.JOIN_EXPANSION entry, 0 for a row count entry
        private final double expansion;
        private final long createTime;
        // which struct info this entry is keyed by; decided at injection time and persisted
        private final LiteralMode literalMode;
        // when a query last applied this entry, and the data state it was applied in; diagnostics
        // only (shown by HBO SHOW STATISTICS), so it is deliberately not persisted: it is not part
        // of the entry, it only tells the user whether the entry is still in use
        private volatile String lastUse = "";

        PinnedHboStatistics(String fingerprint, long rows, PinnedType type, String structCanonical,
                double expansion, LiteralMode literalMode, long createTime) {
            this.fingerprint = fingerprint;
            this.rows = rows;
            this.type = type == null ? PinnedType.EXACT : type;
            this.structCanonical = structCanonical == null ? "" : structCanonical;
            this.expansion = expansion;
            this.literalMode = literalMode == null ? LiteralMode.NO_LITERAL : literalMode;
            this.createTime = createTime;
        }

        /** True when this entry carries a join fan-out factor instead of a row count. */
        public boolean isExpansion() {
            return type == PinnedType.JOIN_EXPANSION;
        }

        /**
         * Remember that a query applied this entry, together with the data state the read side saw
         * when it did (e.g. {@code drifted}, {@code live}). Unlike {@link #getCreateTime()} this is
         * in-memory only: it describes the usage of this FE since it started.
         */
        void recordUse(HboStructFreshness freshness) {
            this.lastUse = TimeUtils.getDatetimeFormatWithTimeZone().format(LocalDateTime.now())
                    + " " + freshness.getSummary();
        }

        /** When a query of this FE last applied this entry (empty when it was never applied here). */
        public String getLastUse() {
            return lastUse;
        }

        /** The join fan-out factor of an {@link PinnedType#JOIN_EXPANSION} entry. */
        public double getExpansion() {
            return expansion;
        }

        /** Which struct info this entry is keyed by. */
        public LiteralMode getLiteralMode() {
            return literalMode;
        }

        public PinnedType getType() {
            return type;
        }

        public String getFingerprint() {
            return fingerprint;
        }

        public long getRows() {
            return rows;
        }

        public long getCreateTime() {
            return createTime;
        }

        public String getStructCanonical() {
            return structCanonical;
        }
    }
}