Skip to content

[Tracking] Changelog and IVM monitoring: metrics and status tables for table streams and IVM materialized views #69907

Open

Description

@morningman

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.

# 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_monitor_last_sample_timestamp_seconds > 120
absent(doris_fe_changelog_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.

-- 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

Metrics: 21 cluster-level and 9 per-object on the FE, 3 on the BE

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_monitor_last_sample_timestamp_seconds gauge when the latest round completed all
doris_fe_changelog_monitor_sample_duration_seconds gauge how long the latest round took all
doris_fe_changelog_monitor_sample_failures_total counter rounds that failed all
doris_fe_changelog_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
System tables: two new tables, and new columns on table_streams, table_stream_consumption and tasks()

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.

FE configs

All are mutable and take effect on the Master FE.

Config Default Meaning
enable_changelog_monitor true with enable_feature_changelog; when off, nothing is sampled, no state series is exported, and the sampled columns are NULL
changelog_monitor_interval_seconds 15 the sampling interval, which is also the precision of the pending age and the data delay
changelog_monitor_max_partitions_per_round 50000 beyond it, the objects are sampled in turns
changelog_monitor_max_points_per_partition 64 change points kept per partition
changelog_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

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_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

ChangelogMonitor is a daemon of the Master FE, as TableStreamManager and CloudTabletStatMgr are. Every changelog_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_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:

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_monitor_max_points_per_partition the two oldest adjacent points merge, which only widens the bound, and changelog_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: [fix](ivm) Keep the offsets of idle IVM stream partitions alive #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

Monitoring

  • T1. Monitor framework. ChangelogMonitor 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_monitor and changelog_monitor_interval_seconds. [feature](metrics) Add the changelog monitor to the Master FE #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. [feature](ivm) Report how a refresh task refreshed an IVM MV, and count the refresh tasks on the Master #69856, merged 2026-11-02.
  • 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. [feature](be) Count the changelog TTL cleanups in the BE metrics #69868, merged 2026-11-02.
  • 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions