Uh oh!
There was an error while loading. Please reload this page.
metric sinks: coordinator per-replica install scaffold (SQL-555) - #38146
metric sinks: coordinator per-replica install scaffold (SQL-555)#38146mtabebe wants to merge 2 commits into
Conversation
964fb48 to
fb90974Compare2ec0280 to
26d069cComparedef-
commented
Aug 25, 2026
QA LLM Review1. MEDIUM -- Indexed storage inputs bypass the introspection-only guard
The introspection-only guard treats an empty Details
|
antiguru
left a comment
There was a problem hiding this comment.
Seems good. I'll let my agent post a review in a second, but I think this structurally checks out.
| Query { | ||
| expr: HirRelationExpr, | ||
| desc: RelationDesc, | ||
| }, |
There was a problem hiding this comment.
👍 Makes sense, and maybe this should be the default anyway. No need to change the PR, we can easily change this in the future.
antiguru
left a comment
There was a problem hiding this comment.
Reviewed the whole diff against the merge-base. The structure is right and mirrors coord::introspection closely enough that the divergences stand out, which is what most of the inline comments are about. Nothing here blocks landing an inert scaffold, but two of the findings are cheaper to fix now than after CURATED is populated, because both are the kind of thing a first definition will hit and neither will surface as a test failure.
The two that matter most. The staged pipeline never extends PlanValidity with the dependencies optimization introduces, which is a step the introspection path takes at the same point and for the reason that applies here too. And the introspection-only contract is enforced by inspecting import kinds after optimization, which decides the question by the target cluster's index layout rather than by what the definition reads, so the same definition passes on mz_catalog_server and soft-panics on every user cluster. Details inline.
A design note worth settling before SQL-556: plan_source plans through the one-shot SELECT path, so finish_maintained never runs on a query that becomes a maintained dataflow. That is the only reason the finishing rejection exists, and the introspection path avoids it by planning as QueryLifetime::Subscribe. Also inline.
On the testing. The PR description is straight about the end-to-end path being unexercised, which I appreciate, but it overstates what the unit tests cover: plan_source_enforces_the_metric_sink_contract has three assertions (valid shape, missing column, ORDER BY) and none for the statement-count rejection the description credits it with, and curated_prefixes_are_valid is not mentioned. The testdrive block is worth calibrating too. With CURATED empty, install_metric_sinks reduces to iterating an empty slice and drop_metric_sinks to an empty range scan, so what it actually asserts is pre-existing user-sink behavior across replica churn. Fine as a harness that starts paying once a definition lands, as long as it is not counted as coverage of this change.
Two things I verified rather than assumed, since both looked like candidate regressions and neither is:
- Dropping the eager
import_into_dataflowfrom theIdpath is a genuine cleanup, not a lost import.import_view_into_dataflowwalksview.depends_on()and callsimport_into_dataflowper leaf, andmaybe_reoptimize_imported_viewsis still called once afterwards. TheIdpath ends up with the same imports it had before. - The module doc's claim that a reconnecting replica needs no reinstall holds.
Instance::remove_replicaproducesERROR_TARGET_REPLICA_FAILEDonly for subscribes and peeks; sinks come back throughrehydrate_replicaand command-history replay, andprotocol/history.rsadvances the replayedas_ofunder allowed compaction, so a stale as-of cannot reach the replica. Worth keeping that reasoning in the doc, it is not obvious.
The label change is behavior-preserving for user sinks: the default self.sink_id.to_string() and export_sink(self.sink_id, ..) use the same id, so the sink label is unchanged. And ComputeSinkConnection has no protobuf representation, so adding a field is serde-only with no wire migration.
One open question I could not answer from the code. I could not confirm that DROP CLUSTER emits per-replica Dropped implications, and if it does not, drop_metric_sinks never runs on that path and the registry keeps entries under dead ReplicaIds. drop_introspection_subscribes has the identical exposure, so this is pre-existing either way and the leak is bounded memory rather than orphaned collections. Still worth an answer before the list grows, since it decides whether teardown belongs on the replica hook alone.
Posted by Claude Code
| let global_lir_plan = (|| { | ||
| // MIR ⇒ MIR optimization (global) | ||
| let global_mir_plan = optimizer.catch_unwind_optimize(metric_sink)?; | ||
| // MIR ⇒ LIR lowering and LIR ⇒ LIR optimization (global) | ||
| optimizer.catch_unwind_optimize(global_mir_plan) | ||
| })() |
There was a problem hiding this comment.
The validity carried into the finish stage never learns about the dependencies optimization introduces. introspection.rs does exactly this at the same point in its pipeline:
let id_bundle = global_mir_plan.id_bundle(cluster_id);let item_ids = id_bundle.iter().map(|id| catalog.resolve_item_id(&id));
validity.extend_dependencies(&catalog, item_ids);Here validity only holds the resolved_ids from plan_source, which are the items the SQL names. The index imports the optimizer selects are not among them, so an index dropped between this stage and the finish stage passes the recheck and the finish stage ships a dataflow importing a collection that no longer exists.
Not reachable today, since CURATED is empty and the introspection log indexes a definition would import are builtin. It becomes reachable as soon as a definition reads through a droppable index, and the failure is silent up to the point the controller rejects the dataflow.
Posted by Claude Code
| // Enforcement site for the introspection-only contract on `CuratedMetricSink::source_sql`. | ||
| // A storage import means the query reads a catalog-backed relation, which puts envd's write | ||
| // frontier on the sink's emission path and would pin that storage `since` for the sink's life. | ||
| if !id_bundle.storage_ids.is_empty() { | ||
| soft_panic_or_log!( | ||
| "curated metric sink reads non-introspection relations (name={}): {:?}", | ||
| definition.name, | ||
| id_bundle.storage_ids | ||
| ); | ||
| return Ok(StageResult::Response(ExecuteResponse::CreatedMetricSink)); | ||
| } |
There was a problem hiding this comment.
This check tests a property that is not the contract, and the answer it gives depends on which cluster the definition is being installed onto.
dataflow_import_id_bundle splits imports by kind, not by what the query reads (optimize/dataflows.rs:276):
let storage_ids = dataflow.source_imports.keys().copied().collect();let compute_ids = dataflow.index_imports.keys().copied().collect();import_into_dataflow prefers an index whenever one exists on the cluster and only falls back to import_source when none does. So a definition reading mz_catalog.mz_objects behaves differently per cluster:
- on
mz_catalog_server, the builtin index exists, the import is an index import, it lands incompute_ids, and the check passes. envd's write frontier is on the emission path anyway, which is precisely what the check is meant to prevent. - on any user cluster, no index exists, the import is a source import, and the check soft-panics. Once per cluster.
Same definition, opposite verdict, decided by one cluster's index layout rather than by the definition.
The contract is a statement about what the definition reads, so it wants to be checked against the resolved dependencies' catalog items in plan_source: every dependency must be a CatalogItem::Log or a view over them. That runs once per definition instead of once per replica, fails before any optimization work, and attributes the error to the definition rather than to a cluster.
Posted by Claude Code
There was a problem hiding this comment.
Ok I have implemented the check, but not in plan_source. The check needs to be done in the coordinator where the CatalogItem is visible.
| let (plan, _sql_impl_ids) = | ||
| mz_sql::plan::plan(Some(&pcx), catalog, stmt, &Params::empty(), &resolved_ids)?; | ||
| let Plan::Select(SelectPlan { | ||
| source, finishing, .. | ||
| }) = plan | ||
| else { | ||
| bail!("source SQL is not a SELECT: {plan:?}"); | ||
| }; | ||
| // A finishing has no meaning for a continuously-consumed collection, and silently dropping | ||
| // one would desync `desc` from `source` (the shaping resolves canonical columns by index | ||
| // into `desc`). Check against `source.arity()`, not `desc.arity()`: a trivial finishing over | ||
| // a wider source would trim columns yet still pass a `desc.arity()` check. | ||
| if !finishing.is_trivial(source.arity()) { | ||
| bail!("source SQL must not use ORDER BY, LIMIT, or OFFSET"); | ||
| } |
There was a problem hiding this comment.
mz_sql::plan::plan on a SELECT statement plans with QueryLifetime::OneShot (plan/statement/dml.rs:197), but the result becomes a maintained dataflow. The one substantive consequence is in plan_root_query:
if lifetime.is_maintained(){
expr.finish_maintained(&mut finishing, group_size_hints);}Under OneShot that never runs, so the finishing is left beside the expression instead of folded into it, which is the only reason the rejection below has to exist at all. A maintained lifetime would fold it in and the question would not arise. allow_show also differs between the two lifetimes, so a curated definition containing SHOW ... plans here and would not under a maintained lifetime.
The introspection path plans its SQL as QueryLifetime::Subscribe, which is the right shape for a continuously-maintained collection. Planning through plan_root_query with a maintained lifetime rather than through Plan::Select would match it and drop the finishing check.
Separately, the error message is inaccurate. is_trivial also fails on a non-identity projection (expr/src/relation.rs:3544):
self.limit.is_none() && self.order_by.is_empty() && self.offset == 0
&& self.project.iter().copied().eq(0..arity)so a definition with no ORDER BY, LIMIT, or OFFSET anywhere can still land on this branch and be told to remove clauses it does not have. Worth naming the projection case in the message, since a definition with a FROM clause is the shape every real definition will take and the tests only cover SELECT-without-FROM.
Posted by Claude Code
There was a problem hiding this comment.
Done, I changed the planning to be a plan with the source as SUBSCRIBE, so it goes through the right life cycle.
| for definition in CURATED { | ||
| self.install_metric_sink(cluster_id, replica_id, definition) | ||
| .await; | ||
| } |
There was a problem hiding this comment.
install_metric_sink calls plan_source inside this loop, so a definition is parsed, resolved, described, and planned once per replica. The plan depends only on the catalog, never on the replica: plan_source takes no replica argument, and the replica enters the picture only at PlanValidity and the optimizer.
At bootstrap that is |CURATED| x |replicas| SQL plans run serially on the coordinator thread, before the message loop starts draining the staged installs, and the same multiplier applies again on every replica create. Planning once per definition and cloning the resulting HirRelationExpr per replica removes the multiplier and keeps a definition's plan errors from being reported once per replica.
Posted by Claude Code
| let install = InstalledMetricSink { | ||
| cluster_id, | ||
| sink_id, | ||
| }; | ||
| if let Some(previous) = self | ||
| .metric_sinks | ||
| .insert((replica_id, definition.name), install) | ||
| { | ||
| // Two definitions collide on the registry key. Restore the first and abandon this one: | ||
| // shipping both would leak the first's collection (now unreachable to | ||
| // `drop_metric_sinks`) and register a second collector under the same `sink` label. | ||
| self.metric_sinks | ||
| .insert((replica_id, definition.name), previous); | ||
| soft_panic_or_log!( | ||
| "metric sink installed twice (name={}, replica_id={replica_id})", | ||
| definition.name | ||
| ); | ||
| return Ok(StageResult::Response(ExecuteResponse::CreatedMetricSink)); | ||
| } |
There was a problem hiding this comment.
Two things about the collision path.
The comment names a cause that curated_names_are_unique already rules out. Two distinct definitions cannot collide on (replica_id, name), since the test fails the build on a duplicate name. The reachable cause is install_metric_sinks running twice for one replica, which is a different bug with a different fix, so the comment sends the next reader looking in the wrong place.
The guard also fires as late as it can. Nothing consults self.metric_sinks at the install site, so a double install pays a full plan_source plus a full optimize per definition and only then discovers the key is taken. A registry check in install_metric_sink before plan_source makes the duplicate cheap and puts the diagnostic next to the caller that caused it. Keeping this one as the backstop is still right.
Posted by Claude Code
| pub(super) fn all_cluster_replicas(&self) -> Vec<(ClusterId, ReplicaId)> { | ||
| self.catalog | ||
| .clusters() | ||
| .flat_map(|cluster| { | ||
| cluster | ||
| .replicas() | ||
| .map(move |replica| (cluster.id, replica.replica_id)) | ||
| }) | ||
| .collect() | ||
| } |
There was a problem hiding this comment.
Sharing this between the two features is right, and the doc comment on it is accurate. Worth spelling out one implication in the metric-sink module doc, though: the set is unfiltered, so it includes every replica of every user cluster alongside the system ones.
That means each curated definition becomes a dataflow, with its arrangements, on customer compute, charged to the customer's cluster. For introspection subscribes that cost is already accepted. For curated metric sinks it is a new charge that neither the module doc nor the PR description mentions, and it scales with |CURATED|. Better to state the intent now than to discover it when the list grows.
Posted by Claude Code
| > ALTER CLUSTER churn_c SET (REPLICATION FACTOR 1) | ||
| # Nothing is asserted while both replicas are up: reading a log relation on a | ||
| # cluster with more than one replica errors as `UntargetedLogRead` unless the | ||
| # session picks one. So this reads the surviving replica's registry, after the | ||
| # churn. | ||
| > SELECT count(*) FROM mz_introspection.mz_cluster_prometheus_metrics | ||
| WHERE metric_name = 'mz_metric_sink_churn_value' |
There was a problem hiding this comment.
This comment contradicts the session state the file already set up. Line 31 does SET cluster_replica = r1 and nothing resets it, so the session has picked a replica for every read in the file, including these. UntargetedLogRead cannot fire here.
Which means the interesting window is assertable: with two replicas up, cluster_replica = r1 reads r1's registry and the count should still be 1. Asserting there, and again after the scale-down, would give the block a claim about replica churn that it currently does not make. As written it reads the same registry twice with the same expectation and the churn between them is unobserved.
Posted by Claude Code
| # of this file. `quickstart` is a managed cluster anyway, which rejects | ||
| # `CREATE CLUSTER REPLICA`, so replication factor is the way to add and remove a | ||
| # replica. | ||
| > CREATE CLUSTER churn_c (SIZE 'scale=1,workers=1') |
There was a problem hiding this comment.
Most of the suite takes the replica size from ${arg.default-replica-size} with a set-arg-default at the top of the file, which is what lets the multi-size testdrive configurations vary it. Hardcoding it here pins this cluster to one size regardless of how the run is configured.
Posted by Claude Code
mtabebe
commented
Aug 26, 2026
Regarding drop, I think it is safe. The way I read the code is: remove_clusters deletes the cluster's replicas, so a DROP CLUSTER diff carries a per-replica ClusterReplica::Dropped. This then drives drop_replica → drop_metric_sinks for each. Nothing is left under dead ReplicaIds. I think it is the same pattern as drop_introspection_subscribes. |
def-
commented
Aug 26, 2026
QA LLM Review1. MEDIUM -- |
7b2d571 to
7d7a5afCompareProblem:
The curated metric-sink library runs on every replica and re-renders on
boot, so it cannot be modeled as a durable catalog item the way a user's
`CREATE METRIC SINK`. That would mean per-boot catalog churn and a
builtin migration every time a definition changes.
Solution:
A new `coord/metric_sink.rs` installs each `CURATED` definition on every
replica, mirroring the introspection-subscribe install: bootstrap walks
the existing replicas, replica create installs, replica drop tears down.
Each sink gets a transient `GlobalId` and is recorded in a
`(replica, definition) -> InstalledMetricSink` registry once its dataflow
ships, so a definition that fails to plan or optimize leaves no entry behind.
A replica dropped mid-install is caught by the staged validity recheck
before the finish stage ships.
Sequencing is staged, so optimization runs off the coordinator thread.
The finish stage picks the as-of under a read hold and ships the dataflow
targeted at the replica.
The install enforces the definition contract at its own site, because a
curated sink has no plan-time gate: the prefix is validated, a source that
imports a storage collection is rejected (curated SQL must read only
introspection relations, so envd's write frontier never joins the emission
path), and a duplicate registry key restores the first install rather than
shipping a second dataflow.
To build a dataflow from curated SQL, the metric-sink optimizer's input
becomes a `MetricSinkFrom::{Id, Query}`, mirroring `SubscribeFrom`.
The flag-off path does not clear existing installs on the next replica
restart: command-history replay re-renders a replica-targeted dataflow
on reconnect, so they persist until the replica is dropped or envd restarts.
The operator's health gauges are labelled with the sink's stable
`CuratedMetricSink::name`, not its transient `GlobalId`, so the series stay
identifiable across boots.
Note:
`CURATED` is empty. Because no definition is installed, the end-to-end pipeline
(install, optimize, pick the as-of under a read hold, ship to the replica, tear
down on replica drop, and the curated label reaching the gauge) does not
run in this PR. The first real definition is next (SQL-556). The tests here
cover only what is reachable without a definition.
Testing:
- An optimizer unit test assembles the `Query` source path and asserts the
same shape as the `Id` path: one `MetricSink` export over the shaped view,
the source imported rather than rebuilt.
- A unit test drives `plan_source` against a debug catalog: it accepts the
canonical-column contract, and rejects a missing column, a non-trivial
finishing (checked against the source arity so shaped columns cannot
desync), and SQL that is not exactly one statement.
- Unit tests cover the `sink` gauge label: a user sink keeps its `GlobalId`,
a curated sink uses its name.
- `curated_names_are_unique` and `curated_definitions_plan` guard the static
list at build time: a duplicate name, which the registry key would collide
on, or a definition that fails to plan is caught as a failing test rather
than a boot-time soft-panic. Both iterate an empty list today.
Co-Authored-By: Moritz Hoffmann <antiguru@gmail.com>- Extend PlanValidity with optimizer-introduced index imports - Enforce the introspection-only contract by provenance (ensure_reads_only_logs) - Plan the curated source as maintained (like SUBSCRIBE) - Cache each definition's plan and install once per definition, not per replica - Check the registry before planning; correct the duplicate-install comment - Document the user-cluster compute cost in the module doc - Assert churn in metric-sink.td
7d7a5af to
8de9792Compare
Problem:
The curated metric-sink library runs on every replica and re-renders on
boot, so it cannot be modeled as a durable catalog item the way a user's
CREATE METRIC SINK. That would mean per-boot catalog churn and abuiltin migration every time a definition changes.
Solution:
A new
coord/metric_sink.rsinstalls eachCURATEDdefinition on everyreplica, mirroring the introspection-subscribe install: bootstrap walks
the existing replicas, replica create installs, replica drop tears down.
Each sink gets a transient
GlobalIdand is recorded in a(replica, definition) -> InstalledMetricSinkregistry once its dataflowships, so a definition that fails to plan or optimize leaves no entry behind.
A replica dropped mid-install is caught by the staged validity recheck
before the finish stage ships.
Sequencing is staged, so optimization runs off the coordinator thread.
The finish stage picks the as-of under a read hold and ships the dataflow
targeted at the replica.
The install enforces the definition contract at its own site, because a
curated sink has no plan-time gate: the prefix is validated, a source that
imports a storage collection is rejected (curated SQL must read only
introspection relations, so envd's write frontier never joins the emission
path), and a duplicate registry key restores the first install rather than
shipping a second dataflow.
To build a dataflow from curated SQL, the metric-sink optimizer's input
becomes a
MetricSinkFrom::{Id, Query}, mirroringSubscribeFrom.The flag-off path does not clear existing installs on the next replica
restart: command-history replay re-renders a replica-targeted dataflow
on reconnect, so they persist until the replica is dropped or envd restarts.
The operator's health gauges are labelled with the sink's stable
CuratedMetricSink::name, not its transientGlobalId, so the series stayidentifiable across boots.
Note:
CURATEDis empty. Because no definition is installed, the end-to-end pipeline(install, optimize, pick the as-of under a read hold, ship to the replica, tear
down on replica drop, and the curated label reaching the gauge) does not
run in this PR. The first real definition is next (SQL-556). The tests here
cover only what is reachable without a definition.
Testing:
An optimizer unit test assembles the
Querysource path and asserts thesame shape as the
Idpath: oneMetricSinkexport over the shaped view,the source imported rather than rebuilt.
A unit test drives
plan_sourceagainst a debug catalog: it accepts thecanonical-column contract, and rejects a missing column, a non-trivial
finishing (checked against the source arity so shaped columns cannot
desync), and SQL that is not exactly one statement.
Unit tests cover the
sinkgauge label: a user sink keeps itsGlobalId,a curated sink uses its name.
curated_names_are_uniqueandcurated_definitions_planguard the staticlist at build time: a duplicate name, which the registry key would collide
on, or a definition that fails to plan is caught as a failing test rather
than a boot-time soft-panic. Both iterate an empty list today.
Co-Authored-By: Moritz Hoffmann antiguru@gmail.com