morningman opened a new issue, #69907:
URL: https://github.com/apache/doris/issues/69907

   This issue tracks monitoring for the consumers of a table's changelog: table 
streams, and IVM materialized views (MVs). It adds Prometheus metrics on the FE 
and the BE, two system tables and new columns on existing ones, and fixes the 
correctness problems found while designing them. Each sub task is one PR to 
master, labeled `incremental-computation`.
   
   Some context. A table with `changelog.enable` keeps its changelog for 
`changelog.ttl_seconds` (one day by default). A table stream keeps a 
consumption offset, a TSO, for each partition of its base table: a DML that 
consumes the stream reads the changes after the offset and moves the offset 
when it commits. An IVM MV stays up to date the same way, through an internal 
stream on each base table (except those in `excluded_trigger_tables`), and 
falls back to rebuilding partitions or the whole MV when the delta cannot be 
applied. Today all of this is visible only through system tables queried one by 
one (`table_streams`, `table_stream_consumption`, `mv_infos()`, `jobs()`, 
`tasks()`). Nothing in `/metrics` watches it.
   
   ## 1. Problems to solve
   
   Five questions users ask, referred to below as S1 to S5.
   
   **S1. Is my MV fresh?** Does a report on an IVM MV show the latest data, how 
far behind is it, and why? `mv_infos()` has a sync flag but no delay in 
seconds. Users also need to tell apart a refresh that is committed but not 
visible yet, an MV partition that needs a rebuild, and a change to a table in 
`excluded_trigger_tables`, which by design triggers no refresh.
   
   **S2. Has my MV stopped refreshing?** Are refreshes failing, how many times 
in a row and why, is a task stuck running or queued? `tasks()` keeps a bounded 
history. The `doris_fe_async_materialized_view_*` metrics mix IVM MVs with the 
other async MVs and one MV with another, time only the successful tasks, and do 
not count the tasks rejected because the task queue was full.
   
   **S3. Has incremental refresh quietly turned into recomputation?** Every 
task succeeds, yet refreshes get slower and the cluster gets busier. The 
`RefreshMode` column of `tasks()` tells which partitions a task covered 
(`COMPLETE`, `PARTIAL`, `NOT_REFRESH`), not whether it applied a delta or 
rebuilt the MV, and nothing counts fallbacks or their reasons.
   
   **S4. Is a stream consumer falling behind, will the TTL catch up with it, 
and has it already lost changes?** `table_stream_consumption` shows the offset 
of each partition, but its `LAG` is a raw TSO difference (the low 18 bits of a 
TSO are a logical counter), neither rows nor milliseconds, and 
`CONSUMPTION_STATUS` is the consumed TSO as text, not a health state. Nothing 
tells how old the oldest unconsumed change is, or how long until the TTL 
expires an offset. A `DETAIL` or `APPEND_ONLY` read from an expired offset 
succeeds and skips the expired changes without a trace.
   
   **S5. How much space does the changelog take, and is it cleaned up?** `SHOW 
DATA` lists `ChangelogSize` on demand, which no alert or trend can use. On the 
BE the TTL cleanup shows only as bvars on brpc's `/vars`, which count what was 
found and submitted, not what was cleaned up or how many bytes came back.
   
   All of this has to hold at scale. Each IVM MV brings an internal stream per 
base table, so a cluster with thousands of MVs has several times as many 
streams. A problem in any of them has to show without the `/metrics` of the 
Master FE growing with every object.
   
   Designing the monitoring also turned up six defects that users hit directly. 
They are fixed here as well; the IDs are the sub tasks in section 4.
   
   - **T0.** Once the TTL expired the offset of an IVM MV's internal stream, 
every refresh of the MV failed, even one allowed to fall back, until someone 
ran a manual `COMPLETE` refresh.
   - **T10.** An IVM MV over a duplicate-key table silently missed rows once 
its offset expired: the refresh succeeded, later refreshes did not bring the 
rows back, and a base-table query that transparent rewrite answered from the MV 
returned the same wrong result.
   - **T11.** An IVM MV silently missed the rows of a base partition its 
internal stream had no offset for (one added after the MV was built, or one 
empty at the time) once those rows were older than the TTL.
   - **T9.** A base partition idle for longer than the TTL makes the refresh 
after its next write rebuild the whole MV, although nothing that refresh needs 
has expired: a dimension table updated weekly, a backfill into an older 
partition, a quiet weekend.
   - **T12.** A user stream read as `MIN_DELTA` (the default on a unique-key 
table), and a stream on a duplicate-key table, silently skip the rows of a 
partition they have no offset for once those rows are older than the TTL, and 
the offset moves past them for good. `MIN_DELTA` even emits `UPDATE_BEFORE` and 
`DELETE` rows for rows the consumer never received.
   - **T8.** A table name containing `"`, `\` or a line feed makes the whole 
`/metrics` response of the Master FE unparseable in cloud mode, where four 
per-table series carry the table name. The MV and stream names on the new 
series would do the same.
   
   ## 2. How users will use it
   
   The names below are as designed; until the PR of a part is merged, its 
review may still change them. Nothing has to be turned on: with the changelog 
feature enabled, the monitor runs on the Master FE by default.
   
   ### Alert from Prometheus
   
   Keep scraping `/metrics` of every FE and BE. Only the Master FE exports the 
state series, and another FE exports none of them rather than 0, so they need 
no filter by node; counters are summed over the FEs after `increase`. Every 
alert signal has a cluster-level series that covers all objects, so it fires 
however many MVs and streams there are. The first 1,000 MVs and the first 1,000 
user streams by ID also get a few series of their own, for per-object alerts.
   
   ```promql
   # S1 An MV is more than 5 minutes behind (for: 2m)
   doris_fe_ivm_mv_data_delay_seconds > 300
   # S1 Some MV, including those beyond the quota, is more than 15 minutes 
behind (for: 5m)
   doris_fe_ivm_mvs_by_data_delay{le="+Inf"} - ignoring(le) 
doris_fe_ivm_mvs_by_data_delay{le="900"} > 0
   # S1 Some MV needs a rebuild, or its state is unknown (for: 10m)
   doris_fe_ivm_mvs{state=~"rebuild_required|unknown"} > 0
   
   # S2 Three failures in a row; failures by reason over all MVs
   doris_fe_ivm_mv_consecutive_failures >= 3
   sum by (reason) (increase(doris_fe_ivm_refresh_failures_total[15m])) > 0
   # S2 A task running for over an hour, a task queued for over 10 minutes, a 
rejected task
   doris_fe_ivm_task_oldest_running_age_seconds > 3600
   doris_fe_ivm_task_oldest_pending_age_seconds > 600
   sum(increase(doris_fe_ivm_schedule_rejected_total[10m])) > 0
   
   # S3 Fallbacks in the last hour; one MV fell back 3 times or more
   sum by (reason) (increase(doris_fe_ivm_fallbacks_total[1h])) > 0
   increase(doris_fe_ivm_mv_fallbacks_total[1h]) >= 3
   
   # S4 A stream cannot resume; less than 30 minutes before the TTL reaches an 
offset
   #    (with kind="ivm" it warns that the next refresh of an MV can only 
recompute)
   doris_fe_changelog_streams{state="cannot_resume"} > 0
   doris_fe_changelog_stream_min_retention_headroom_seconds < 1800
   # S4 A consumption skipped expired changes (one stream / all of them)
   increase(doris_fe_changelog_stream_gap_events_total[10m]) > 0
   sum(increase(doris_fe_changelog_stream_gap_commits_total[10m])) > 0
   # S4 A stream has had a change waiting for more than 10 minutes
   doris_fe_changelog_stream_pending_age_seconds > 600
   
   # S5 A TTL cleanup failed
   sum(increase(doris_be_changelog_ttl_cleanup_total{result="failed"}[30m])) > 0
   
   # The monitoring itself is stale: no round completed for 2 minutes, or no 
Master exports it
   time() - doris_fe_changelog_ivm_monitor_last_sample_timestamp_seconds > 120
   absent(doris_fe_changelog_ivm_monitor_last_sample_timestamp_seconds)
   ```
   
   The thresholds are examples, to be set from the freshness target, the usual 
refresh time and the TTL. Delay and gap alerts are best grouped per MV or per 
stream, with a link to the queries below, so that thousands of partitions do 
not alert at once. S2 alerts can be silenced while a job is paused on purpose; 
the delay still shows.
   
   ### Find the cause with SQL
   
   The alert tells that something is wrong and how badly; the system tables 
tell which MV or stream, which base table, which partition, and why. Grafana's 
MySQL data source shows them as table panels.
   
   ```sql
   -- S1, S2: which MVs are not fresh, and why
   SELECT DB_NAME, MV_NAME, FRESHNESS_STATE, DATA_DELAY_SECONDS, 
DELAY_PRECISION,
          LAGGING_REASON, CONSECUTIVE_FAILURES, LAST_FAILURE_REASON, SAMPLE_TIME
   FROM information_schema.ivm_mv_status
   WHERE FRESHNESS_STATE <> 'FRESH'
   ORDER BY DATA_DELAY_SECONDS DESC;
   
   -- S1: which base table holds an MV back
   SELECT BASE_TABLE_DB, BASE_TABLE_NAME, IS_EXCLUDED_TRIGGER, DEPENDENCY_STATE,
          DELAY_SECONDS, LAGGING_PARTITIONS, WORST_PARTITION, STREAM_ID
   FROM information_schema.ivm_dependencies
   WHERE DB_NAME = 'demo' AND MV_NAME = 'mv_orders'
   ORDER BY DELAY_SECONDS DESC;
   
   -- S1, S4: the partitions behind, and how close the TTL is
   SELECT UNIT, CONSUMED_TSO, SOURCE_END_TSO, PENDING_AGE_SECONDS, 
PENDING_AGE_PRECISION,
          CAN_RESUME, RETENTION_HEADROOM_SECONDS, LAST_GAP_TIME
   FROM information_schema.table_stream_consumption
   WHERE STREAM_ID = 12345
   ORDER BY PENDING_AGE_SECONDS DESC
   LIMIT 20;
   
   -- S2, S3: how the recent tasks refreshed, why they fell back, and the query 
ID for the profile
   SELECT TaskId, Status, RefreshMode, IvmRefreshMethod, IvmFallbackReason,
          DurationMs, ErrorMsg, LastQueryId
   FROM tasks("type" = "mv")
   WHERE MvDatabaseName = 'demo' AND MvName = 'mv_orders'
   ORDER BY CreateTime DESC
   LIMIT 20;
   
   -- S4: the user streams at risk
   SELECT DB_NAME, STREAM_NAME, MONITOR_STATE, MAX_PENDING_AGE_SECONDS,
          MIN_RETENTION_HEADROOM_SECONDS, CAN_RESUME, GAP_EVENTS, LAST_GAP_TIME
   FROM information_schema.table_streams
   WHERE IVM_MV_ID IS NULL AND MONITOR_STATE <> 'CAUGHT_UP'
   ORDER BY MIN_RETENTION_HEADROOM_SECONDS;
   
   -- S5: the changelog of one table
   SHOW DATA FROM demo.orders;
   ```
   
   ### Dashboards
   
   Three pages, shipped with the documentation (T7):
   
   1. **Overview**: MVs and streams by state, the largest delay and the delay 
buckets, the smallest headroom, fallbacks and failures by reason over the last 
hour, the oldest queued and the longest running task, the changelog size, the 
health of the monitor, and a table of the MVs in `ivm_mv_status` that are not 
fresh.
   2. **One MV**: delay and state over time, consecutive failures, fallbacks, 
the last duration; tables of its rows in `ivm_dependencies` and its recent 
`tasks()`.
   3. **One stream**: pending age, headroom, state and gaps over time; a table 
of its worst partitions in `table_stream_consumption`.
   
   ### Reference
   
   <details><summary>Metrics: 21 cluster-level and 9 per-object on the FE, 3 on 
the BE</summary>
   
   **FE, cluster-level** (about 110 series, however many objects there are)
   
   | Metric | Type | Labels | Meaning | For |
   | --- | --- | --- | --- | --- |
   | `doris_fe_changelog_streams` | gauge | `kind` = user, ivm; `state` = the 7 
stream states | streams in each state | S4 |
   | `doris_fe_changelog_stream_max_pending_age_seconds` | gauge | `kind` | the 
largest known pending age | S4 |
   | `doris_fe_changelog_stream_min_retention_headroom_seconds` | gauge | 
`kind` | the smallest known retention headroom, may be negative | S4, S3 |
   | `doris_fe_changelog_read_window_events_total` | counter | `outcome` = 
expired_rejected, expired_clipped; `consumer` = stream, ivm, query | TTL 
rejections and clips when a read is planned, once per planning, retries and 
uncommitted queries included | S4 |
   | `doris_fe_changelog_stream_gap_commits_total` | counter | | consuming 
transactions committed after a clipped read | S4 |
   | `doris_fe_changelog_retained_bytes` | gauge | `scope` = single_replica, 
all_replicas | the changelog the tables keep; cloud mode exports single_replica 
only | S5 |
   | `doris_fe_ivm_mvs` | gauge | `state` = the 5 freshness states | IVM MVs in 
each state | S1 |
   | `doris_fe_ivm_mv_max_data_delay_seconds` | gauge | | the largest known 
data delay | S1 |
   | `doris_fe_ivm_mvs_by_data_delay` | gauge | `le` = 60, 300, 900, 3600, +Inf 
| MVs whose delay is at most `le` seconds, cumulative, known values only | S1 |
   | `doris_fe_ivm_mvs_excluded_dependency_outdated` | gauge | | MVs with an 
excluded dependency changed and not covered | S1 |
   | `doris_fe_ivm_refresh_tasks_total` | counter | `result` = success, 
no_change, failed, canceled; `method` = none, incremental, partition_rebuild, 
complete, mixed | finished refresh tasks, each counted once | S2, S3 |
   | `doris_fe_ivm_refresh_duration_seconds_total` | counter | `method` | run 
time of the tasks that succeeded or failed; divided by the above, the average | 
S3 |
   | `doris_fe_ivm_fallbacks_total` | counter | `reason` | tasks that fell back 
| S3 |
   | `doris_fe_ivm_refresh_failures_total` | counter | `reason` | tasks that 
failed | S2 |
   | `doris_fe_ivm_schedule_rejected_total` | counter | | refresh tasks 
rejected because the task queue was full | S2 |
   | `doris_fe_ivm_task_oldest_pending_age_seconds` | gauge | | how long the 
oldest queued refresh task has waited, 0 if none | S2 |
   | `doris_fe_ivm_task_oldest_running_age_seconds` | gauge | | how long the 
longest running refresh task has run, 0 if none | S2 |
   | `doris_fe_changelog_ivm_monitor_last_sample_timestamp_seconds` | gauge | | 
when the latest round completed | all |
   | `doris_fe_changelog_ivm_monitor_sample_duration_seconds` | gauge | | how 
long the latest round took | all |
   | `doris_fe_changelog_ivm_monitor_sample_failures_total` | counter | | 
rounds that failed | all |
   | `doris_fe_changelog_ivm_monitor_objects_not_exported` | gauge | `type` = 
mv, stream | objects beyond the quota, which have no per-object series | all |
   
   `doris_fe_changelog_read_window_events_total` is counted by every FE that 
plans a read. The five refresh counters (`refresh_tasks`, 
`refresh_duration_seconds`, `fallbacks`, `refresh_failures`, 
`schedule_rejected`) are exported by every FE and counted on the Master only, 
so sum them over the FEs. Everything else comes from the Master only. The 
counters held by the Master start from 0 after a restart or a Master switch, as 
any counter reset.
   
   **FE, per object** (from the Master, under the quota)
   
   | Metric | Type | Meaning |
   | --- | --- | --- |
   | `doris_fe_ivm_mv_data_delay_seconds` | gauge | data delay, -1 when unknown 
|
   | `doris_fe_ivm_mv_freshness_state` | gauge | the freshness state as a 
number |
   | `doris_fe_ivm_mv_consecutive_failures` | gauge | consecutive failures |
   | `doris_fe_ivm_mv_fallbacks_total` | counter | fallbacks; persisted, so it 
stays monotonic across restarts and Master switches |
   | `doris_fe_ivm_mv_last_refresh_duration_seconds` | gauge | run time of the 
latest task that succeeded or failed |
   | `doris_fe_changelog_stream_state` | gauge | the stream state as a number |
   | `doris_fe_changelog_stream_pending_age_seconds` | gauge | the largest 
pending age over the partitions, -1 when unknown |
   | `doris_fe_changelog_stream_retention_headroom_seconds` | gauge | the 
smallest headroom over the partitions with a TTL; not exported when no 
partition has one or it is unknown |
   | `doris_fe_changelog_stream_gap_events_total` | counter | committed 
consumptions that skipped expired changes; persisted |
   
   The MV series carry `db_id`, `db_name`, `mv_id` and `mv_name`; the stream 
series, exported for user streams only, carry `db_id`, `db_name`, `stream_id` 
and `stream_name`.
   
   **BE**
   
   | Metric | Type | Labels | Meaning |
   | --- | --- | --- | --- |
   | `doris_be_changelog_ttl_cleanup_total` | counter | `result` = success, 
failed | TTL cleanups that finished |
   | `doris_be_changelog_ttl_reclaimed_bytes_total` | counter | | bytes of 
expired changelog the cleanups dropped |
   | `doris_be_changelog_ttl_last_success_timestamp_seconds` | gauge | | when a 
cleanup last succeeded |
   
   </details>
   
   <details><summary>System tables: two new tables, and new columns on 
<code>table_streams</code>, <code>table_stream_consumption</code> and 
<code>tasks()</code></summary>
   
   **`information_schema.ivm_mv_status`** (new): one row per IVM MV, from the 
latest round, filtered by the MV's SHOW privilege as `mv_infos()` is.
   
   | Column | Meaning |
   | --- | --- |
   | `DB_NAME`, `MV_NAME`, `MV_ID` | the MV |
   | `FRESHNESS_STATE` | the freshness state |
   | `DATA_DELAY_SECONDS`, `DELAY_PRECISION` | the data delay, and `PRECISE`, 
`UPPER_BOUND` or `UNKNOWN` |
   | `LAGGING_REASON` | why the MV lags |
   | `LAGGING_DEPENDENCIES` | trigger dependencies with changes the MV does not 
cover yet |
   | `DIRTY_PARTITIONS` | MV partitions in the maintained range that need a 
rebuild |
   | `PENDING_PUBLISH_TXNS` | refresh transactions committed and not visible 
yet |
   | `EXCLUDED_OUTDATED_DEPENDENCIES`, `MAX_EXCLUDED_DELAY_SECONDS` | excluded 
dependencies that changed since the MV covered them, and the largest of their 
delays |
   | `MIN_RETENTION_HEADROOM_SECONDS` | the smallest headroom of its internal 
streams; a low one means the next refresh may only recompute |
   | `JOB_STATUS` | the state of the refresh job |
   | `CURRENT_TASK_ID`, `CURRENT_TASK_STATUS`, `CURRENT_TASK_AGE_SECONDS` | the 
task running or queued |
   | `CONSECUTIVE_FAILURES` | consecutive failures |
   | `LAST_SUCCESS_TIME`, `LAST_FAILURE_TIME`, `LAST_FAILURE_REASON`, 
`LAST_ERROR_MESSAGE` | the error message is cut to 512 characters |
   | `LAST_REFRESH_METHOD`, `LAST_REFRESH_DURATION_MS` | of the latest task 
that succeeded or failed |
   | `FALLBACK_COUNT`, `LAST_FALLBACK_REASON`, `LAST_FALLBACK_TIME` | fallbacks 
|
   | `SAMPLE_TIME` | the round the values come from |
   
   The failure, fallback and duration columns come from a summary persisted on 
the MV, so the bounded task history does not limit them.
   
   **`information_schema.ivm_dependencies`** (new): one row per IVM MV and 
direct base table, from the latest round, with the same privilege check.
   
   | Column | Meaning |
   | --- | --- |
   | `DB_NAME`, `MV_NAME`, `MV_ID` | the MV |
   | `BASE_TABLE_CTL`, `BASE_TABLE_DB`, `BASE_TABLE_NAME`, `BASE_TABLE_ID` | 
the base table |
   | `IS_EXCLUDED_TRIGGER` | the table is in `excluded_trigger_tables` |
   | `STREAM_NAME`, `STREAM_ID` | the internal stream, NULL for an excluded 
dependency; join `table_stream_consumption` on `STREAM_ID` for the partitions |
   | `DEPENDENCY_STATE` | `USABLE`, `MISSING`, `DISABLED`, `STALE`, 
`CANNOT_RESUME` or `EXCLUDED` |
   | `COVERED` | the visible data of the MV covers every change of this 
dependency |
   | `LAGGING_PARTITIONS`, `WORST_PARTITION` | the source partitions not 
covered, and the one with the largest delay |
   | `DELAY_SECONDS`, `DELAY_PRECISION` | the delay of this dependency |
   | `COVERED_VERSION`, `CURRENT_VERSION` | for an excluded dependency: the 
version the MV's refresh snapshots cover, and the visible one |
   | `MIN_RETENTION_HEADROOM_SECONDS`, `CAN_RESUME` | for a trigger dependency |
   | `SAMPLE_TIME` | the round the values come from |
   
   **`information_schema.table_streams`**, new columns: one row per stream, 
from the latest round.
   
   | Column | Meaning |
   | --- | --- |
   | `IVM_MV_ID` | the MV that owns an internal stream; NULL for a user stream |
   | `MONITOR_STATE` | the stream state |
   | `PENDING_PARTITIONS` | partitions not caught up |
   | `MAX_PENDING_AGE_SECONDS`, `PENDING_AGE_PRECISION` | the largest pending 
age over the partitions, and its precision |
   | `CAN_RESUME` | every partition with a TTL can resume; NULL when none has 
one |
   | `MIN_RETENTION_HEADROOM_SECONDS` | the smallest headroom over the 
partitions with a TTL |
   | `GAP_EVENTS`, `LAST_GAP_TIME` | how many committed consumptions skipped 
expired changes, and when the latest committed; persisted |
   | `MONITOR_SAMPLE_TIME` | the round the values come from; NULL before the 
first one |
   
   **`information_schema.table_stream_consumption`**, new columns: one row per 
stream and source partition, computed at query time like the existing columns, 
the pending age from the sampler's change points. `CONSUMPTION_STATUS`, `LAG` 
and `LAST_CONSUMPTION_TIME` stay as they are.
   
   | Column | Meaning |
   | --- | --- |
   | `SOURCE_END_TSO` | the visible end of the source partition; NULL when it 
has no data |
   | `CONSUMED_TSO` | the committed offset, inclusive; NULL when there is none |
   | `OFFSET_STATE` | `CONSUMED`, `INITIAL_SNAPSHOT_PENDING` or `NO_OFFSET` |
   | `CAUGHT_UP` | the offset is at the visible end |
   | `POSITION_GAP_SECONDS` | the physical time between the offset and the end; 
for trends only, it is not the pending age |
   | `PENDING_AGE_SECONDS`, `PENDING_AGE_PRECISION` | the pending age, 0 when 
caught up |
   | `RETENTION_APPLICABLE` | the table has a TTL and the partition has an 
offset |
   | `RETENTION_FLOOR_TSO`, `NEXT_REQUIRED_TSO`, `CAN_RESUME`, 
`RETENTION_HEADROOM_SECONDS` | see "Definitions" below; NULL when not 
applicable |
   | `LAST_GAP_TIME` | when a consumption that skipped expired changes of this 
partition last committed |
   
   **`tasks("type"="mv")`**, new column `IvmRefreshMethod`: how the task 
refreshed an IVM MV, `NONE`, `INCREMENTAL`, `PARTITION_REBUILD`, `COMPLETE` or 
`MIXED`; NULL while the task runs and for an MV that is not an IVM MV. 
`RefreshMode`, `IvmFallbackReason` and `IvmRebuiltPartitions` stay as they are.
   
   </details>
   
   <details><summary>FE configs</summary>
   
   All are mutable and take effect on the Master FE.
   
   | Config | Default | Meaning |
   | --- | --- | --- |
   | `enable_changelog_ivm_monitor` | true | with `enable_feature_changelog`; 
when off, nothing is sampled, no state series is exported, and the sampled 
columns are NULL |
   | `changelog_ivm_monitor_interval_seconds` | 15 | the sampling interval, 
which is also the precision of the pending age and the data delay |
   | `changelog_ivm_monitor_max_partitions_per_round` | 50000 | beyond it, the 
objects are sampled in turns |
   | `changelog_ivm_monitor_max_points_per_partition` | 64 | change points kept 
per partition |
   | `changelog_ivm_monitor_max_points` | 1000000 | change points kept in total 
|
   | `prom_output_ivm_mv_metrics_limit` | 1000 | MVs with per-object series; 0 
for none |
   | `prom_output_table_stream_metrics_limit` | 1000 | user streams with 
per-object series; 0 for none |
   | `enable_ivm_stream_keepalive` | true | when off, no new keep-alive TSO is 
recorded; the recorded ones still count |
   | `ivm_stream_keepalive_interval_second` | 600 | the keep-alive round; keep 
it well under half the shortest `changelog.ttl_seconds` of the IVM base tables |
   
   </details>
   
   ## 3. Design
   
   ### Metrics detect, system tables locate
   
   The two carriers can do different things:
   
   | | Metrics (`/metrics` to Prometheus) | System tables 
(`information_schema`, TVFs) |
   | --- | --- | --- |
   | Alerting | evaluated continuously, unattended | needs an external program 
that runs SQL |
   | History and rates | kept by the time-series database | the current state 
only |
   | Events between two looks | a counter misses none | a snapshot misses them 
unless they are persisted |
   | Cost | per series, paid on every scrape | per query, with filters |
   | Exact values | float64, which cannot hold a TSO (about 4.7×10¹⁷, above 
2⁵³) | exact BIGINT |
   | Text | finite enums only | error messages, query IDs, dependency lists |
   | Privileges | no per-object privilege check on the FE's `/metrics` | 
filtered by object privilege |
   
   So, in this order:
   
   1. A signal that needs unattended alerting or a trend is a metric, and 
always has a cluster-level series first, so that no number of objects hides a 
problem.
   2. An event that can happen between two samples (a clipped read, a fallback, 
a failure, a rejected task) is a counter. When it has to be traced back to an 
object, the object also persists a summary, a count and the latest time, which 
a system table shows.
   3. Details that grow with partitions, tablets, tasks or transactions, and 
exact TSOs and versions, error text and dependency lists, go into system tables 
only.
   4. The key health values of an object (data delay, state, consecutive 
failures) are in both: the system table has every object with the reason, and 
the metrics carry a few golden signals per object, under a quota.
   
   Both sides come from the same sampler and the same computation, so they 
cannot disagree.
   
   ### A bounded number of series
   
   Exporting every signal for every object would cost about 80 series per IVM 
MV (`reason × phase` and `requested × actual × result` alone have dozens of 
combinations) and 23 per stream. With 2,000 MVs over 3 base tables each (6,000 
internal streams) and 1,000 user streams, that is about 320,000 series and 50 
MB per scrape of the Master FE, whose `/metrics` is rendered under one lock 
(`MetricRepo.getMetric` is `static synchronized`). Doris already caps the 
per-table series of cloud mode with `prom_output_table_metrics_limit` (#50586), 
and the BE exports tablet-level metrics only on `?with_tablet=true`. So:
   
   - **Cluster-level aggregates by default**: counts by state, the worst values 
(the largest delay, the smallest headroom), delay buckets, and event counters 
over finite enums. About 110 series, however many objects there are, and every 
object counts in them.
   - **A few golden signals per object**: 5 series per IVM MV and 4 per user 
stream. Reasons, phases and methods appear at cluster level only, and there is 
no histogram per object. A state is a number, not a label value, so a change of 
state does not start a new series; a commit-triggered MV can switch between 
fresh and lagging every minute.
   - **No series per internal stream**: the delay and the headroom of an MV 
already take the worst of its internal streams, whose details are in 
`ivm_dependencies` and `table_stream_consumption`. The cluster-level stream 
series tell user and internal streams apart with `kind`.
   - **A quota per object type**: `prom_output_ivm_mv_metrics_limit` and 
`prom_output_table_stream_metrics_limit`, named after 
`prom_output_table_metrics_limit`, 1,000 each, 0 to turn them off. Beyond the 
quota the objects with the lowest IDs are exported, which is a stable choice, 
and `doris_fe_changelog_ivm_monitor_objects_not_exported{type}` tells how many 
are left out. They still count in the cluster-level series.
   - **Labels carry stable identities only**: the database and object ID and 
name, as the per-job series of routine load do. No partition, tablet, task, 
transaction, offset or error text. Label values are escaped (T8).
   
   With the default quotas that is at most about 9,100 series, about 1 MB.
   
   Not chosen: exporting the worst N objects only (they move in and out 
quickly, which breaks the `for:` of an alert), exporting the unhealthy objects 
only (a missing series could then mean healthy, not sampled or dropped), one 
series per object with the state in a label (a new series at every state 
change), and putting the per-object series behind a separate scrape parameter 
as the BE's `with_tablet` does (every small cluster would need a second scrape 
job).
   
   ### One sampler on the Master FE
   
   `ChangelogIvmMonitor` is a daemon of the Master FE, as `TableStreamManager` 
and `CloudTabletStatMgr` are. Every `changelog_ivm_monitor_interval_seconds` 
(15 by default) it runs a round:
   
   1. Under each object's lock it only copies: offsets; the IDs, visible 
versions, TSOs and visible times of the base partitions; the partition states 
and refresh snapshots of the MVs; the job and task states. This is what 
`table_stream_consumption` does today, and no RPC is sent under a metadata lock.
   2. Outside the locks, in cloud mode, it reads every stream partition of the 
round in one batched MetaService call, as the system table does.
   3. It updates the change points (below), computes the values of every 
object, and publishes an immutable snapshot by replacing one reference. A round 
that fails keeps the previous snapshot and counts the failure, and 
`last_sample_timestamp` shows how old the snapshot is.
   4. Beyond `changelog_ivm_monitor_max_partitions_per_round` partitions, the 
objects are sampled in turns, each with its own sample time.
   
   `/metrics` only renders the latest snapshot, through a `MetricVisitor` 
method beside `visitCloudTableStats`, instead of registering the series again 
on every scrape. Only the Master exports the state series. Another FE exports 
none of them rather than 0, because a 0 from every other FE, as the routine 
load lag gauges give today, drags a `min` over the FEs to 0. A new Master 
exports none until its first round completes, which the `absent()` alert above 
covers. Events are counted on the FE where they happen (refresh task events on 
the Master, TTL rejections and clips on the FE that plans the query), never in 
replay.
   
   ### Definitions
   
   **Resume and retention headroom** are computed with the functions the 
planner uses (`ChangelogUtils`, `TSOTimestamp`), not an approximation of them:
   
   ```text
   cutoff           = calculateCutoff(current TSO, ttl_seconds)
   retention_floor  = nextTso(cutoff)
   next_required    = nextTso(C)                      C is the offset, inclusive
   can_resume       = next_required >= retention_floor
   headroom_seconds = (physical(next_required) - physical(retention_floor)) / 
1000, may be negative
   ```
   
   They apply when the base table has a TTL and the partition has an offset. A 
stream caught up on a partition nobody writes still drifts out of the 
retention: its next `MIN_DELTA` read is rejected and its next `DETAIL` or 
`APPEND_ONLY` read clipped, and a user stream reports that as `cannot_resume`. 
An IVM internal stream uses its effective offset `max(C, K)` with the 
keep-alive TSO K (below), so an idle one does not.
   
   **Pending age** is the age of the oldest change that the source has made 
visible and the offset does not cover, 0 when caught up. A `Partition` keeps 
only its latest visible version, time and TSO, and compaction merges the TSO 
ranges of rowsets, so the time a change first became visible cannot be 
recovered afterwards. The sampler observes it instead. Whenever the visible end 
of a tracked partition has advanced since the previous round, it records a 
change point: the new end TSO and version, and the window in which those 
changes became visible, from the previous round to this one. For a consumer at 
offset C, the first point beyond C bounds the age, and the upper end of the 
bound is reported, so a delay is never reported too small.
   
   - One new version in a round gives the exact time (the visible time of the 
version, or in cloud mode the physical time of its TSO); several are off by one 
sampling interval at most. Both are `PRECISE`.
   - After a Master switch or a restart there is no history: a consumer already 
behind reports `now - physical(C)`, `UPPER_BOUND`, until it catches up once.
   - An offset that cannot be read (a cloud read state `UNKNOWN`, a failed 
round) gives no value, `UNKNOWN`.
   - A partition with data and no offset yet counts from the creation of the 
stream, `UPPER_BOUND`. A stream whose initial snapshot is not consumed yet is 
`initializing` and has no pending age.
   
   A partition keeps only the points beyond the smallest offset of its 
consumers, so consumers that are caught up keep none. Beyond 
`changelog_ivm_monitor_max_points_per_partition` the two oldest adjacent points 
merge, which only widens the bound, and `changelog_ivm_monitor_max_points` 
(about 32 MB) caps the total. Change points are not persisted: losing them all 
only falls back to the `UPPER_BOUND` rule. A value exact across restarts would 
need new persisted metadata on the commit and publish paths and in MetaService; 
this does without.
   
   **MV data delay** is the largest of:
   
   1. The delay of each trigger dependency over the maintained range (the MV's 
partition sync range, when it has one), computed as above with the internal 
stream at the MV's *visible coverage* instead of its current offset. The offset 
moves when the refresh transaction is COMMITTED, before the new data of the MV 
is VISIBLE, so in shared-nothing mode the sampler takes, for the refresh 
transactions committed and not visible yet, the offsets from before them 
(recorded in `OlapTableStreamUpdate`). In cloud mode a commit is visible at 
once.
   2. The age of a dirty MV partition in the maintained range: from the round 
before the one that first saw it dirty (`PRECISE`), or, with no history, from 
the MV's latest successful refresh (`UPPER_BOUND`).
   
   It is 0 when everything is covered and no partition is dirty, and has no 
value for an MV that never refreshed successfully (`initializing`). Its 
precision is the worst of its parts. A chain of IVM MVs is measured against the 
direct dependencies only. Tables in `excluded_trigger_tables` are left out of 
it on purpose: their changes are not meant to trigger a refresh, and counting 
them would keep such MVs over the threshold for good. Their coverage is judged 
from the versions in the MV's refresh snapshots and reported on its own 
(`EXCLUDED_OUTDATED_DEPENDENCIES`, `MAX_EXCLUDED_DELAY_SECONDS`, 
`doris_fe_ivm_mvs_excluded_dependency_outdated`).
   
   **States**: the first one that matches, with the number the per-object gauge 
exports.
   
   | Stream state | Number | When |
   | --- | --- | --- |
   | `disabled` | 6 | `ENABLED = false` |
   | `stale` | 5 | `IS_STALE = true`, including a dropped base table |
   | `unknown` | 4 | the round failed, or an offset cannot be read |
   | `cannot_resume` | 3 | a partition with a TTL cannot resume: the next read 
is rejected (`MIN_DELTA`) or clipped (`DETAIL`, `APPEND_ONLY`) |
   | `initializing` | 2 | the initial snapshot is not consumed yet |
   | `lagging` | 1 | a partition has changes beyond its offset, or data and no 
offset |
   | `caught_up` | 0 | otherwise |
   
   | MV freshness state | Number | When |
   | --- | --- | --- |
   | `unknown` | 4 | the round failed, an internal stream is missing, or an 
offset cannot be read |
   | `initializing` | 3 | never refreshed successfully |
   | `rebuild_required` | 2 | a dirty partition in the maintained range |
   | `lagging` | 1 | changes not covered yet, including a refresh being 
published |
   | `fresh` | 0 | otherwise |
   
   `LAGGING_REASON`, in the system table only, is the first that matches of 
`REBUILD_REQUIRED`, `PUBLISHING` (a refresh committed and not visible yet), 
`RUNNING`, `QUEUED`, `REFRESH_FAILED`, `JOB_PAUSED`, `WAITING_SCHEDULE` (a 
scheduled refresh not due yet) and `WAITING_TRIGGER` (a commit-triggered or 
manual refresh, no task yet); `NONE` when fresh.
   
   **Refresh result, method and fallback.** Each finished task counts once:
   
   - `result`: `success`, `no_change` (a success that committed no MV data), 
`failed` or `canceled`. Consecutive failures go up on `failed`, back to 0 on 
`success` and `no_change`, and stay on `canceled`.
   - `method`, from the attempts that committed data: `none`, `incremental`, 
`partition_rebuild`, `complete`, or `mixed`, typically the rebuild of dirty 
partitions before the delta. It is a different dimension from `RefreshMode`.
   - A **fallback** is a task that planned an incremental attempt, moved to a 
partition rebuild or a complete rebuild for an IVM reason, and committed data. 
Its reason is `IvmFallbackReason`: a value of `IvmFailureReason`, or 
`INCOMPLETE_REFRESH_SNAPSHOT`. Not fallbacks: building the first baseline, an 
explicit `COMPLETE` or `PARTITIONS` request, rebuilding dirty partitions before 
the delta, a fallback that committed nothing, and a canceled task.
   - A **failure reason** is the IVM reason when falling back is not allowed, 
and otherwise the stage that threw: `PARTITION_SYNC_FAILED`, `QUERY_UNUSABLE`, 
`EXECUTION_FAILED` or `OTHER`, told apart by where the failure was thrown 
rather than by its message. An expired offset is `CHANGELOG_BROKEN` (T0).
   
   Label values on metrics are lower case.
   
   **Unknown values.** System tables give NULL, with a state or precision 
column that says why. Per-object gauges give -1 for an unknown delay or age, as 
the per-job lag of streaming jobs does; a headroom can be negative, so an 
unknown or inapplicable one is not exported. Cluster-level maxima and minima 
count the known values only, and an object with unknown values counts in 
`state="unknown"`. No NaN.
   
   ### What is persisted, and where events are counted
   
   - **Refresh summary of an MV.** `MTMVTask` persists its method, its failure 
reason and whether it fell back. The MV keeps a summary that 
`MTMV.addTaskResult` updates, the same code on the Master and in replay, so it 
is deterministic, survives restarts and Master switches, and does not depend on 
the bounded task history: consecutive failures, the latest success and failure 
with the reason and the message, the latest method and duration, and the 
fallback count with the latest reason and time. A canceled task leaves it as it 
is. The refresh counters count in `MTMVTask.after()` on the Master and never in 
replay; a task the full task queue rejects counts in 
`doris_fe_ivm_schedule_rejected_total`.
   - **TTL rejections, clips and gaps.** The two places that apply the TTL, the 
stream scan (`NormalizeOlapTableStreamScan.applyChangelogTtl`) and the direct 
incremental query (`BindRelation.applyChangelogTtl`), count each rejection or 
clip in `doris_fe_changelog_read_window_events_total`, except a clip of a 
partition with no change after the offset, which loses nothing. A consuming DML 
records the partitions it clipped in `OlapTableStreamUpdate`; when the 
transaction is COMMITTED, the stream counts a gap event and the time of the 
latest gap of each of those partitions, journaled and replayed (replay does not 
count `doris_fe_changelog_stream_gap_commits_total`). In cloud mode MetaService 
keeps them, in two new optional fields of `TableStreamOffsetPB` 
(`gap_event_count`, `last_gap_time_ms`) that it updates in the transaction that 
moves the offset.
   - **TTL cleanup on the BE.** A TTL cleanup is a cumulative compaction of a 
changelog tablet that leaves the expired rowsets out of the merge. It counts 
once when the compaction finishes (`CumulativeCompaction::execute_compact`, and 
in cloud mode `CloudCumulativeCompaction::record_compaction_success` or 
`record_compaction_failure`), and only when its input held expired rowsets; a 
compaction that never started, for example on a busy lock, does not count. The 
reclaimed bytes are the `total_disk_size` of the expired rowsets a successful 
cleanup dropped. The existing bvars stay.
   - **Changelog size** sums `OlapTable.Statistics` over the tables with a 
changelog, which `TabletStatMgr` or `CloudTabletStatMgr` refreshes periodically.
   
   ### Keep-alive TSO for idle IVM partitions (T9)
   
   An offset moves only when a refresh consumes the partition, and a refresh 
consumes only the partitions with new data, so the offset of a partition nobody 
writes stays at its last change. The TTL check compares that offset with `now - 
TTL` and cannot know that nothing was written in between, so the first write 
after a day of idleness makes the next refresh reject the offset and rebuild 
the whole MV.
   
   The Master records a keep-alive TSO K for the idle partitions of IVM 
internal streams, meaning that the partition holds no change in (C, K], and the 
TTL check of an IVM read uses `max(C, K)`. The offset C itself does not move.
   
   - A daemon runs a round every `ivm_stream_keepalive_interval_second` (600 by 
default) over the partitions that are caught up, past the initial snapshot, on 
a base table with a TTL, and with less than half the TTL left.
   - In shared-nothing mode K is a TSO taken under the base table read lock: a 
commit takes its TSO under the write lock, so any later commit gets a larger 
one. A partition with a committed transaction that is not visible yet is 
skipped.
   - In cloud mode K is the committed watermark of the TSO service. The round 
first advances MetaService's TSO fence to K, so that no commit at or below K 
can still land, then reads the offsets in one batched call.
   - K is kept in FE metadata with the offset it was taken against, and a read 
uses it only while the offset is still that one.
   - A round that fails records nothing and leaves the partition to the next 
round. The refresh after a write stays incremental when it starts within half 
the TTL minus one interval after the write, 11 hours 50 minutes with the 
defaults.
   
   User streams get no keep-alive, so the monitoring reports an idle user 
stream as `cannot_resume`.
   
   ### Compatibility
   
   - **Persisted state** is new GSON fields only, which an older FE ignores, so 
a rollback is safe, and the new cloud proto fields are optional. One exception: 
#69831 merges the per-partition maps of `OlapTableStream` into one map. Table 
streams are not released yet, so a stream from an older image has to be dropped 
and created again.
   - **New columns on existing tables.** The BE scanners check how many columns 
the FE returns. `tso_status` once added columns by checking whether the request 
carries `columns_name`, which works only once, and these tables get columns 
twice (T2, then T5). So the FE fills the columns by the names the request lists 
(NULL for a name it does not know, and only the original columns for a request 
without names), and a new BE accepts an FE that returns fewer trailing columns 
and fills them with NULL. A cluster with mixed versions can query both tables 
whichever is upgraded first.
   - **New tables** append values to `TSchemaTableType` and `TSchemaTableName` 
and never reuse one (`gensrc/AGENTS.md`).
   - **Privileges.** The two new tables are filtered by the MV's SHOW 
privilege; the extended tables keep their current visibility rules.
   
   ## 4. Sub tasks
   
   `[x]` merged, `[ ]` in review or not started. The IDs are stable references, 
not the order of work.
   
   ### Fixes
   
   - [x] **T0. Fall back to COMPLETE when an IVM stream offset has expired.** 
#69772, merged 2026-10-28.
   - [x] **T10. Reject an expired offset in every IVM read, including one of a 
duplicate-key base table.** #69785, merged 2026-10-29.
   - [x] **T11. Read a base partition with no offset from the base table in an 
IVM refresh.** #69778, merged 2026-10-29.
   - [ ] **T9. Keep the offsets of idle IVM stream partitions alive**, in 
shared-nothing and cloud mode. #69831, in review.
   - [ ] **T12. Read a partition with no offset from the base table for user 
streams read as `MIN_DELTA`, and for streams on duplicate-key tables.** Within 
the TTL the output is the same as before; past it, the rows come back. 
`APPEND_ONLY` and `DETAIL` streams on unique-key tables stay as they are, 
because reading the base table would change their output within the TTL. The PR 
follows #69831, which it shares a test fixture with.
   - [ ] **T8. Escape label values in the Prometheus output of the FE**, with 
one function for the existing series and the new ones. #69837, in review.
   
   ### Monitoring
   
   - [ ] **T1. Monitor framework.** `ChangelogIvmMonitor` on the Master, the 
immutable snapshot and its rendering in `/metrics`; 
`doris_fe_changelog_retained_bytes{scope}` and the three series on the health 
of the monitor; `enable_changelog_ivm_monitor` and 
`changelog_ivm_monitor_interval_seconds`. #69848, in review.
   - [ ] **T3. How a refresh refreshed.** `IvmRefreshMethod` in `tasks()`; the 
method, failure reason and fallback persisted with each task; the refresh 
summary of an MV; `doris_fe_ivm_refresh_tasks_total`, 
`doris_fe_ivm_refresh_duration_seconds_total`, `doris_fe_ivm_fallbacks_total`, 
`doris_fe_ivm_refresh_failures_total` and 
`doris_fe_ivm_schedule_rejected_total`. #69856, in review.
   - [ ] **T6. TTL cleanup metrics on the BE.** 
`doris_be_changelog_ttl_cleanup_total{result}`, 
`doris_be_changelog_ttl_reclaimed_bytes_total` and 
`doris_be_changelog_ttl_last_success_timestamp_seconds`. #69868, in review.
   - [ ] **T2. Stream monitoring.** Offsets, resume, headroom, state, change 
points and pending age, for user and internal streams; 
`doris_fe_changelog_streams`, 
`doris_fe_changelog_stream_max_pending_age_seconds`, 
`doris_fe_changelog_stream_min_retention_headroom_seconds`, and the per-stream 
state, pending age and headroom; the new columns of `table_streams` and 
`table_stream_consumption` except the gap columns; the per-object quota with 
`prom_output_table_stream_metrics_limit` and 
`objects_not_exported{type="stream"}`; sampling in turns and the three sampler 
limits. After T1, T8 and T9.
   - [ ] **T4. IVM freshness.** The visible coverage, data delay, state, 
lagging reason and excluded dependencies of an MV; `doris_fe_ivm_mvs`, 
`doris_fe_ivm_mv_max_data_delay_seconds`, `doris_fe_ivm_mvs_by_data_delay`, 
`doris_fe_ivm_mvs_excluded_dependency_outdated`, 
`doris_fe_ivm_task_oldest_pending_age_seconds` and 
`doris_fe_ivm_task_oldest_running_age_seconds`; the five per-MV series with 
`prom_output_ivm_mv_metrics_limit` and `objects_not_exported{type="mv"}`; the 
new tables `ivm_mv_status` and `ivm_dependencies`. After T1, T2 and T3.
   - [ ] **T5. TTL events and gaps.** 
`doris_fe_changelog_read_window_events_total`, 
`doris_fe_changelog_stream_gap_commits_total` and the per-stream 
`doris_fe_changelog_stream_gap_events_total`; the clipped partitions in 
`OlapTableStreamUpdate` and the gap record of a stream; the gap columns; the 
two optional fields of `TableStreamOffsetPB` and their update in MetaService. 
After T2.
   - [ ] **T7. Documentation and dashboard.** User documentation in English and 
Chinese, the alert rules above, and a Grafana dashboard. After T0 to T6 and T8.
   


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

To unsubscribe, e-mail: [email protected]

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


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

Reply via email to