|
| 1 | +# Runtime control of environmentd metric cardinality |
| 2 | + |
| 3 | +- Associated: [DB-199](https://linear.app/materializeinc/issue/DB-199), [INC-1252](https://app.incident.io/materializeinc/incidents/1252), [#38616](https://github.com/MaterializeInc/materialize/pull/38616) |
| 4 | + |
| 5 | +## The Problem |
| 6 | + |
| 7 | +environmentd exposes one `/metrics` endpoint whose sample count is proportional |
| 8 | +to catalog size: many families carry one series per catalog object, persist |
| 9 | +shard, replica or cluster. The AMP scraper rejects any response over 50 MiB, |
| 10 | +which is not configurable on our side. In INC-1252 the largest environment in |
| 11 | +the fleet (about 14k catalog objects) returned about 58 MB and 450k samples |
| 12 | +across 531 families, so every scrape failed with `body size limit exceeded` |
| 13 | +and every environmentd metric for the environment disappeared for hours, |
| 14 | +twice. Recovery was coincidental, when the sample count dipped back under the |
| 15 | +limit. The environment is growing, so it will cross the limit again. |
| 16 | + |
| 17 | +Until now there was no runtime control over which families or which scopes |
| 18 | +environmentd exports. The only levers were a code change and a rollout. |
| 19 | + |
| 20 | +The largest families on the affected endpoint, from the incident channel: |
| 21 | + |
| 22 | +| Family | Samples | Labels | |
| 23 | +|---|---|---| |
| 24 | +| `mz_dataflow_wallclock_lag_seconds{,_sum,_count}` | 61,548 | instance_id, replica_id, collection_id | |
| 25 | +| `mz_time_to_first_row_seconds` | 52,080 | instance_id, isolation_level, strategy, application_name | |
| 26 | +| `mz_timestamp_difference_for_strict_serializable_ms` | 34,125 | compute_instance | |
| 27 | +| `mz_compute_peek_duration_seconds` | 31,176 | instance_id, result | |
| 28 | +| `mz_object_info` | 14,488 | one per catalog object | |
| 29 | + |
| 30 | +Of the 173 labeled metric families in environmentd-side crates, 92 scale with |
| 31 | +objects, replicas or clusters: 43 per persist shard (Persist), 3 per |
| 32 | +collection per replica and 29 per replica or per cluster in the compute and |
| 33 | +storage controllers (Cluster), 5 per catalog object and 2 per unbounded |
| 34 | +`application_name` in the adapter (SQL). |
| 35 | + |
| 36 | +## Success Criteria |
| 37 | + |
| 38 | +- An operator can shed environmentd samples at runtime through `ALTER SYSTEM`, |
| 39 | + with no restart, and get well under the scrape limit. |
| 40 | +- The shedding can be scoped: per-replica or per-object detail can be kept for |
| 41 | + named clusters or replicas while dropped elsewhere. Per-replica metrics are |
| 42 | + used daily to compare old and new generation replicas during rollouts and to |
| 43 | + track OOM-looping replicas, so all-or-nothing is not acceptable. |
| 44 | +- Under growth, one family degrades at a time rather than the whole endpoint. |
| 45 | +- Payload size and per-family sample counts are observable before the limit is |
| 46 | + reached. |
| 47 | +- Every knob defaults to current behavior. |
| 48 | + |
| 49 | +## Out of Scope |
| 50 | + |
| 51 | +- What clusterd exposes. clusterd endpoints were not near the limit. |
| 52 | +- Changes to the metrics pipeline (compression, remote-write batching). These |
| 53 | + are tracked on the Cloud side as INC-1252 follow-ups. |
| 54 | +- Deciding which families to delete outright. That is a per-team audit; |
| 55 | + [#38614](https://github.com/MaterializeInc/materialize/pull/38614) is the |
| 56 | + first instance. |
| 57 | + |
| 58 | +## Solution Proposal |
| 59 | + |
| 60 | +Two layers. Layer 1 is one chokepoint owned by one team and is the incident |
| 61 | +fix, implemented in #38616. Layer 2 is per-owner work that removes the cost at |
| 62 | +the source. |
| 63 | + |
| 64 | +### Layer 1: export-side filter at the registry chokepoint |
| 65 | + |
| 66 | +`mz_ore::metrics::MetricsRegistry::gather` runs a list of postprocessors over |
| 67 | +the gathered families. The compute controller already uses one to add a |
| 68 | +`workload_class` label to every series carrying `instance_id`. The export |
| 69 | +filter (`mz_metrics::export_filter`) is a second postprocessor, installed once |
| 70 | +per environmentd instance from `mz_environmentd::serve`, that drops families |
| 71 | +and series before encoding according to four dyncfgs. All are |
| 72 | +`ParameterScope::Environment` and default to no filtering. |
| 73 | + |
| 74 | +| dyncfg | Type | Default | Effect | |
| 75 | +|---|---|---|---| |
| 76 | +| `metrics_export_disabled_families` | comma list, trailing `*` glob | `""` | Families whose name matches are removed. | |
| 77 | +| `metrics_export_cluster_allowlist` | comma list of cluster IDs | `""` (all) | Series carrying `instance_id`, `cluster_id` or `compute_instance` are kept only for the listed clusters. Series without such a label, or with an empty value, are unaffected. | |
| 78 | +| `metrics_export_replica_allowlist` | comma list of replica IDs | `""` (all) | The same for `replica_id`. | |
| 79 | +| `metrics_export_max_series_per_family` | usize | `0` (unlimited) | A family with more exported samples than this is dropped whole and counted in a self-metric. | |
| 80 | + |
| 81 | +Semantics as predicates over the output of one gather, for every retained |
| 82 | +family `f` not named with the `mz_metrics_export_` prefix, with `S(f)` its |
| 83 | +series and `samples(f)` the number of text-format lines it encodes to: |
| 84 | + |
| 85 | +- `not glob_match(disabled_families, f.name)` |
| 86 | +- `for all s in S(f) with a cluster label: cluster_allowlist is empty or label value is empty or label value in cluster_allowlist` |
| 87 | +- `for all s in S(f) with a replica label: replica_allowlist is empty or label value is empty or label value in replica_allowlist` |
| 88 | +- `max == 0 or samples(f) <= max` |
| 89 | +- `S(f)` is non-empty |
| 90 | + |
| 91 | +Design decisions: |
| 92 | + |
| 93 | +- **Samples, not label sets.** The cap and the series gauge count what the |
| 94 | + text encoder emits. A histogram series expands to one line per bucket plus |
| 95 | + `+Inf`, `_count` and `_sum`, so counting label sets would undercount |
| 96 | + histogram-heavy families by an order of magnitude, and those are exactly the |
| 97 | + families that dominated the incident. |
| 98 | +- **Whole-family drop at the cap, never truncation.** A truncated family is an |
| 99 | + arbitrary subset that looks complete on a dashboard. An absent family is |
| 100 | + visibly absent, and the `*_info` dashboards already fall back when a family |
| 101 | + is missing. |
| 102 | +- **Self-metrics are exempt.** The filter's own families are never filtered, |
| 103 | + so a small cap or a broad prefix cannot hide the metrics that explain what |
| 104 | + was dropped. |
| 105 | +- **Configuration is read from the live system dyncfg set.** environmentd |
| 106 | + already holds an `Arc<ConfigSet>` built from every dyncfg that the storage |
| 107 | + controller updates on every system-config change. The filter reads the four |
| 108 | + raw values at each gather and re-parses only when they change, so there is |
| 109 | + no catalog round-trip per scrape, no process-global state, and no separate |
| 110 | + update hook to keep in sync. The parsed configuration is shared through an |
| 111 | + `Arc` swapped under a short mutex hold, so a coordinator config update never |
| 112 | + waits on an in-flight filter pass. |
| 113 | +- **Per-instance, not per-process.** The test harness starts several |
| 114 | + environmentd instances in one process, each with its own registry, so the |
| 115 | + filter is installed from `serve` rather than from the binary's `main`. |
| 116 | +- **Where it applies.** The postprocessor filters every consumer of |
| 117 | + `gather()`. `/metrics/public` gathers environmentd's own families through it |
| 118 | + and then merges clusterd-sourced series it fetches separately, so the filter |
| 119 | + governs environmentd's own series only. That is the intended scope. |
| 120 | + |
| 121 | +Self-metrics, registered by the filter: |
| 122 | + |
| 123 | +| Metric | Labels | Purpose | |
| 124 | +|---|---|---| |
| 125 | +| `mz_metrics_export_series` (gauge) | `family` | Samples exported per family at the previous gather, after filtering. This is the leading indicator we lacked during the incident. | |
| 126 | +| `mz_metrics_export_dropped_series_total` (counter) | `family`, `reason` in {`disabled_family`, `cluster_allowlist`, `replica_allowlist`, `over_cap`} | What the filter is doing. | |
| 127 | +| `mz_metrics_export_encoded_bytes` (gauge) | none | Size of the most recently encoded internal `/metrics` response. | |
| 128 | + |
| 129 | +Suggested alert: `mz_metrics_export_encoded_bytes > 30 MiB` fleet-wide, well |
| 130 | +under the 50 MiB limit. `topk(10, mz_metrics_export_series)` gives the |
| 131 | +reduction candidates per environment. |
| 132 | + |
| 133 | +Cost: gather still materializes every registered series before the filter |
| 134 | +runs. The filter fixes the payload and the scrape, not the process-side CPU |
| 135 | +and memory of maintaining the series. That is Layer 2. |
| 136 | + |
| 137 | +### Layer 2: emission-side gating per owner |
| 138 | + |
| 139 | +Each per-object family gets a dyncfg that stops series from being created, so |
| 140 | +the process stops paying for them and leaks are bounded. Ordered by sample |
| 141 | +contribution in the incident environment. |
| 142 | + |
| 143 | +- **Cluster: `mz_dataflow_wallclock_lag_seconds{,_sum,_count}`.** Created per |
| 144 | + collection per replica by `wallclock_lag_metrics` in `mz_cluster_client`, |
| 145 | + from the compute controller and the storage controller. Proposal: dyncfg |
| 146 | + `wallclock_lag_metrics_scope` with values `all` (default), `replica` |
| 147 | + (aggregate over collections into a per-replica family), `none`. |
| 148 | + Per-collection lag remains queryable through |
| 149 | + `mz_internal.mz_wallclock_global_lag_history`. |
| 150 | +- **SQL: the `application_name` label** on `mz_time_to_first_row_seconds` and |
| 151 | + `mz_adapter_commands`. The label is client-controlled and unbounded, and a |
| 152 | + histogram multiplies it by about 19 buckets. Proposal: dyncfg |
| 153 | + `adapter_metrics_application_name_label` with values `full` (default), |
| 154 | + `allowlist` (names not in a configured list collapse to `other`), `off`. |
| 155 | +- **Persist: `ShardsMetrics`.** 43 families with one series per shard. |
| 156 | + Proposal: dyncfg `persist_shard_metrics_enabled` in `PersistConfig`. When |
| 157 | + false, `ShardMetrics::new` builds unregistered local metric instances so |
| 158 | + call sites keep working with no branching. |
| 159 | +- **SQL: the `*_info` families.** A zero |
| 160 | + `catalog_info_metrics_reconcile_interval` stops reconciliation but leaves |
| 161 | + existing series in place. Change: zero also clears the series. |
| 162 | +- **Cluster: per-replica and per-cluster controller metrics.** Bounded by |
| 163 | + replica and cluster count rather than object count. Cover with Layer 1 |
| 164 | + allowlists first. |
| 165 | + |
| 166 | +### Cross-cutting: declare cardinality class at the definition |
| 167 | + |
| 168 | +`metric!` accepts `tags:` and `visibility:` as documentation-only metadata |
| 169 | +consumed by `bin/gen-metrics-catalog`. Add a `scales_with:` field with values |
| 170 | +`Object`, `Shard`, `Collection`, `Replica`, `Cluster`, `Client`, keep a |
| 171 | +name-to-class map in the registry at registration, and let |
| 172 | +`metrics_export_disabled_families` accept `class:per_object` in addition to |
| 173 | +family names. A metrics-catalog lint that fails CI when a metric has a |
| 174 | +scaling label and no class keeps the inventory above from rotting. |
| 175 | + |
| 176 | +## Minimal Viable Prototype |
| 177 | + |
| 178 | +#38616 implements Layer 1 in full: the four dyncfgs, the postprocessor, the |
| 179 | +self-metrics, unit tests for every rule, and an environmentd integration test |
| 180 | +that flips each dyncfg through `ALTER SYSTEM` and scrapes `/metrics`. CI |
| 181 | +system-parameter defaults run the filter path with a non-existent probe family |
| 182 | +and a high cap so the postprocessor executes in every mzcompose-based test |
| 183 | +without changing what tests observe. The drop branches themselves are covered |
| 184 | +by the unit and integration tests, not by the CI defaults. |
| 185 | + |
| 186 | +## Alternatives |
| 187 | + |
| 188 | +- **Emission-side gating only.** Fixes process cost as well as payload, but |
| 189 | + needs a change in each owning crate and gives no single lever during an |
| 190 | + incident. It is Layer 2, sequenced after the chokepoint. |
| 191 | +- **Gzip on the endpoint.** About 10x smaller payload, but AMP's limit is |
| 192 | + checked against the decoded body in practice and compression does not |
| 193 | + reduce sample count. Tracked on the Cloud side as a complementary |
| 194 | + mitigation. |
| 195 | +- **Truncating an over-cap family** instead of dropping it. Rejected because a |
| 196 | + partial family is indistinguishable from a complete one on a dashboard. |
| 197 | +- **A process-global filter updated through `mz_metrics::update_dyncfg`.** |
| 198 | + The first implementation. Rejected because the test harness runs several |
| 199 | + environmentd instances per process and the coordinator's config update |
| 200 | + would have contended with an in-flight filter pass. |
| 201 | + |
| 202 | +## Open questions |
| 203 | + |
| 204 | +1. Should `/metrics/public` also filter the clusterd-sourced series it merges |
| 205 | + in? The current scope is environmentd's own series only. |
| 206 | +2. Is per-collection wallclock lag as a Prometheus metric load-bearing for any |
| 207 | + alert, or is `mz_wallclock_global_lag_history` sufficient? Decides whether |
| 208 | + the `replica` aggregate in Layer 2 is needed or `none` is enough. |
| 209 | +3. Should the cap default to a non-zero value in production once the |
| 210 | + self-metrics have a few weeks of data? |
0 commit comments