morningman commented on issue #65423:
URL: https://github.com/apache/doris/issues/65423#issuecomment-4933938651
Two suggestions on where this feature should go beyond fixing the gaps
listed above, plus one item @morningman is taking.
## 1. Expose the counters as `information_schema` tables rather than `SHOW`
statements
`SHOW QUERY STATS` today has five hard-coded output shapes, each with its
own `ShowResultSetMetaData` and its own `QueryStatsUtil` entry point. None can
be filtered, sorted, joined, or aggregated. The questions users actually have
are all one `WHERE` clause away, and none is expressible:
- *"every column in this database with `filter_count > 100` and `query_count
= 0`"*
- *"join against `information_schema.columns` to find wide `STRING` columns
nobody reads"*
- *"which rollups have served zero queries"*
Moving the data into system tables makes all of that ordinary SQL and lets
the `SHOW` statements survive as thin views. It also matches the broader
direction of consolidating ad-hoc `SHOW` commands into queryable metadata.
### The two grains
The counters have two natural grains, and conflating them is the mistake to
avoid. An object's `query_count` is "how many queries touched this object" —
one increment per query, held today in `IndexStats.queryHit`. A column's
counters are per-column booleans folded into that same query. A table's count
is therefore **not** derivable by summing its columns; it is the sum over its
indexes. So: one table per grain.
**`information_schema.column_query_stats`** — one row per `(frontend,
catalog_name, table_schema, table_name, index_name, column_name)`, with
`query_count` and `filter_count`.
**`information_schema.object_query_stats`** — one row per `(frontend,
catalog_name, table_schema, table_name, table_type, index_name,
partition_name)`, with `query_count`. `index_name` is `NULL` for external
tables; `partition_name` is `NULL` when the table is unpartitioned.
**`information_schema.query_stats_status`** — one row per frontend:
`enabled`, `stats_since`. Small, but it closes a real hole: the counters
accumulate since FE start or the last `CLEAN`, with no window and no decay, and
today nothing tells you which. It also turns the most common failure — one FE
left at `enable_query_hit_stats = false` because someone used `ADMIN SET
FRONTEND CONFIG` instead of `ADMIN SET ALL FRONTENDS CONFIG` — into a visible
row rather than mysteriously low numbers.
Note that `column_query_stats` deliberately carries **no** `partition_name`.
Whether a column is filtered on is a property of the plan, not of a partition;
pushing that dimension down to column grain multiplies rows by the partition
count for a signal nobody has asked for. Partition hotness lives in
`object_query_stats`; column hotness lives in `column_query_stats`.
### Row-count budget, and why a pre-filter is mandatory
This scale question decides the design rather than being an afterthought.
Take 10,000 tables × 10,000 partitions: `object_query_stats` is 10<sup>8</sup>
rows at full materialization. `column_query_stats` is tables × indexes ×
columns — 10,000 tables with 100 columns and 3 indexes is another
3×10<sup>6</sup>. A schema-table scan serializes those rows over thrift from FE
to BE. Naively that is tens of gigabytes of `TRow` for a query whose answer is
one line.
The good news: **both mechanisms this needs already exist in the
schema-table framework.** They have simply never been treated as a framework.
**(a) Predicate pushdown into the FE generator.** `SchemaScanNode` already
serializes arbitrary conjuncts into
`TSchemaTableRequestParams.frontend_conjuncts` (thrift field 7), and
`FrontendConjunctsUtils.convertToExpression()` / `isFiltered()` let a generator
evaluate them **per candidate row before serializing it**. `sql_block_rule` and
`view_dependency` already use this. So
```sql
SELECT * FROM information_schema.object_query_stats
WHERE table_schema = 'test_db' AND table_name = 'orders';
```
can be answered by walking only that table's `IndexStats`, never
materializing the other 9,999.
The critical design consequence: **the generator must consult the conjuncts
while walking the in-memory `catalogStats` tree, not after building the full
row list.** Filtering after materialization buys nothing — the cost being
avoided is row construction and serialization, not the network.
To make that reliable rather than best-effort, the leading columns should be
exactly the ones that prune the walk: `catalog_name` → `table_schema` →
`table_name` → `index_name` → `partition_name`. Those map onto the
`catalogStats → dataBaseStats → tableStats → indexStats` nesting, so an
equality predicate on any prefix short-circuits a level of the traversal.
Predicates on `query_count` / `filter_count` cannot prune the walk and are just
row filters.
**(b) FE fan-out already has a working precedent.**
`MetadataGenerator.forwardToOtherFrontends()` does exactly this, driven by
`TSchemaTableRequestParams.replay_to_other_fe`.
`information_schema.active_queries` uses it: the BE scanner sets the flag
(`be/src/information_schema/schema_active_queries_scanner.cpp:63`), the
receiving FE produces its own rows, then re-sends the request with the flag
cleared to every peer and concatenates the results.
That is precisely the shape Query Stats needs — each FE only counts the
queries it planned itself, so any read must fan out and merge. It means the
work is *generalizing an existing mechanism*, not inventing one. Today, by
contrast, every feature that needs a cross-FE merge hand-rolls it against
`ClientPool.frontendPool`: `QueryStatsUtil.getStats()` (with its own
per-`TQueryStatsType` merge branches), `ShowProcessListCommand`,
`StatisticsCache`, `FollowerColumnSender`, `FESessionMgr`.
Two things to fix while generalizing:
- `forwardToOtherFrontends()` swallows per-FE failures with a `LOG.warn` and
continues — the same silent undercount `QueryStatsUtil` has today. With
`frontend` as a real column, an unreachable FE becomes a *missing row*, which a
join against `query_stats_status` makes visible. Worth preserving deliberately
rather than inheriting by accident.
- `frontend_conjuncts` must be forwarded along with the relayed request, so
peers prune too. Otherwise the pre-filter saves the coordinating FE's work and
none of the others'.
**(c) When pushdown is absent, degrade loudly rather than silently.** For a
bare `SELECT * FROM information_schema.object_query_stats` on a
10<sup>8</sup>-row cluster the options are: stream in batches, cap and warn, or
refuse. I'd argue for a **required-predicate rule** on `object_query_stats`
once the estimated row count exceeds a threshold — reject with "add a
`table_schema` or `table_name` predicate" — because the alternative is an
unbounded FE-side allocation the user cannot cancel. `column_query_stats` and
`query_stats_status` are small enough to serve unfiltered.
Batching is the complementary answer if unfiltered scans must work at all.
`TFetchSchemaTableDataResult` returns a single `dataBatch`, and
`TSchemaTableRequestParams` has no offset/limit today, so streaming needs a
cursor field added. That is strictly more work than pushdown and helps only the
unfiltered case, so it should follow pushdown rather than precede it.
### What this buys
With `frontend` as a real column and pushdown into the walk, summation moves
out of Java and into the SQL layer:
```sql
-- Today's `SHOW QUERY STATS FROM t ALL`, but now filterable and joinable
SELECT index_name, SUM(query_count)
FROM information_schema.object_query_stats
WHERE table_schema = 'test_db' AND table_name = 'stats_table'
GROUP BY index_name;
-- Cold partitions, pruned at the FE before a single row is serialized
SELECT partition_name, SUM(query_count) AS hits
FROM information_schema.object_query_stats
WHERE table_schema = 'ods' AND table_name = 'orders'
GROUP BY partition_name HAVING hits = 0;
-- Wide string columns nobody ever reads
SELECT s.table_name, s.column_name, c.data_type
FROM information_schema.column_query_stats s
JOIN information_schema.columns c
ON c.table_schema = s.table_schema
AND c.table_name = s.table_name
AND c.column_name = s.column_name
WHERE c.data_type IN ('string', 'varchar')
GROUP BY s.table_name, s.column_name, c.data_type
HAVING SUM(s.query_count) = 0;
-- Prefix-key candidates: heavily filtered, rarely projected
SELECT column_name, SUM(filter_count) AS f, SUM(query_count) AS q
FROM information_schema.column_query_stats
WHERE table_schema = 'ods' AND table_name = 'orders'
GROUP BY column_name ORDER BY f DESC;
```
The five `QueryStatsUtil` merge branches, one per `TQueryStatsType`,
disappear. The `SHOW` statements can survive as views for compatibility.
## 2. Statistics dimensions: table / index / partition / MV / column
Correcting the current state, because it is easy to misread:
| Dimension | Status | Evidence |
|---|---|---|
| Table | supported | `StatsDelta` keyed by `catalogId_dbId_tableId_indexId`
|
| Column | supported | `IndexStats.columnQueryStats` / `columnFilterStats` |
| **Index (ROLLUP)** | **already supported** | The delta key includes
`PhysicalOlapScan.getSelectedIndexId()`; `SHOW QUERY STATS FROM t ALL
[VERBOSE]` breaks down by index name via `OlapTable.getIndexNameById()`. A
**sync MV is an index**, so it rides the same path. |
| **MTMV (async MV)** | **recorded — but the attribution is wrong** | `MTMV
extends OlapTable`, and `BindRelation` binds `TableType.MATERIALIZED_VIEW`
through `makeOlapScan()` → `PhysicalOlapScan`. An MTMV scan *is* counted, under
the MTMV's own table id. The problem is **transparent rewrite**: the base
table's scan is replaced by the MTMV's, so the base table's columns record
nothing and the table looks cold precisely because it is being queried
successfully. |
| **Partition** | **missing, and never existed** |
`PhysicalOlapScan.getSelectedPartitionIds()` is right there at plan time. Note
#18805's original `StatsDelta` had no partition dimension either — only
catalog/db/table/index plus replicas. |
| Tablet / replica | dead code | Distinct from partition: the old
`OlapScanNode.genStatsDelta()` passed `scanReplicaIds`, and replica selection
under Nereids happens after fragment assignment, so this cannot be a plan-tree
hook. |
Two concrete asks follow.
**Add the partition dimension.** The ids are already on the scan node, so
the recorder change is small. The cost is cardinality: a table with thousands
of partitions multiplies its counter entries accordingly, and today those
counters are an unbounded `ConcurrentHashMap` in `Env` with no TTL. That is an
independent argument for suggestion 1 — a schema table backed by an explicitly
managed structure, rather than a map that only ever grows.
**Decide the MTMV attribution semantics.** When a query against a base table
is transparently rewritten onto an MTMV, I'd argue the base table's columns
should still record `queryHit`: the user's intent was to read those columns,
and the entire purpose of the counter is to tell you which columns matter for
schema design. The MTMV should of course also record its own hit, so `WHERE
table_type = 'MTMV'` answers "which MVs are earning their keep." Under the
schema above both rows coexist naturally; under the current `SHOW`-only model
there is nowhere to put them.
## 3. External tables (@morningman will take this)
Currently out of scope by design — `walkPlan` recognizes only
`PhysicalOlapScan`. Worth noting that the structural work is smaller than it
looks: `PhysicalOlapScan`, `PhysicalFileScan`, and `PhysicalOdbcScan` all
extend **`PhysicalCatalogRelation`**, which already exposes `getTable()` and
`getDatabase()` — the two things `getOrCreateDelta()` needs. `PhysicalFileScan`
even carries `selectedPartitions`, so Hive/Iceberg partition hotness lands in
the same `object_query_stats` shape proposed above.
Two things fall out once external tables are in:
- The `index_name` column becomes genuinely nullable, which retires the
`TableStats.DEFAULT_INDEX_ID = -1` sentinel still lurking in the read path
(`QueryStats.getTblStats()`) as leftover from #18805.
- `catalog_name` stops being decorative. Today every row is the internal
catalog; the delta key already carries `catalogId` and the `SHOW` statements
already accept a catalog level, so the plumbing exists and has simply never had
a second catalog to hold.
--
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]