Skip to content

compute: retry metric sink collector registration - #38910

Merged
mtabebe merged 3 commits into
MaterializeInc:mainfrom
mtabebe:ma/metrics/fix-upgrade-test
Sep 29, 2026
Merged

mtabebe merged 3 commits into
MaterializeInc:mainfrom
mtabebe:ma/metrics/fix-upgrade-test

Conversation

@mtabebe

@mtabebe mtabebe commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Problem:

A curated metric sink keys its gauges on its stable definition
name, so every incarnation shares one prometheus Desc id. When a
restarted environmentd reconciles against a still-running replica, the
old incarnation and the new one live in the same process, and nothing
sequences the old dataflow's teardown (which drops its registration)
before the new one renders. Registration collided and
register_collector_with_dropper soft-panicked.

Solution:

Add MetricsRegistry::try_register_collector_with_dropper, a
fallible variant that returns the prometheus error, and re-base the
soft-panicking one on it so its callers keep the backstop. The sink
operator owns a PendingRegistration and retries once a second,
self-scheduling via its activator so a quiescent input still gets
another try. The guard lives in the operator closure, so it drops with
the dataflow, freeing the Desc id for the retrying incarnation.

A new counter, mz_compute_metric_sink_registration_retries_total,
makes retries visible.

@mtabebe
mtabebe force-pushed the ma/metrics/fix-upgrade-test branch 2 times, most recently from 3f23cab to 8051e57 Compare September 18, 2026 08:42
@mtabebe
mtabebe marked this pull request as ready for review September 18, 2026 10:20
@mtabebe
mtabebe requested review from a team as code owners September 18, 2026 10:20
@def-

def- commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- Moving the registration guard into the operator closure unregisters the collector when the operator shuts down, not when the sink is dropped

src/compute/src/sink/metric_sink.rs:215

render_sink now returns None and keeps the registration guard inside the operator's logic closure. Timely drops that closure as soon as the operator shuts down, which happens whenever both input frontiers reach the empty antichain, not only when the dataflow is torn down. A metric sink whose source collection finishes (a constant-folding source) therefore unregisters its collector moments after it is created and publishes nothing, silently and permanently; before this change the guard lived in sink_token and survived operator shutdown.

Details

The operator is built with OperatorBuilder::build, not build_reschedule, so its logic always reports "not incomplete" (timely wraps it as move |frontier| { logic(frontier); false }). Timely's Subgraph::activate_child then does: if the child reported not-incomplete and frontiers_empty && no_capabilities, call child.shut_down(), which sets operator = None and drops the logic closure. The metric sink holds no capabilities (op.build(move |_capabilities| ...) drops the minted vec; the operator has no outputs), so the only condition is empty input frontiers.

Concretely reachable: CREATE VIEW v AS SELECT 'm'::text AS metric_name, 'gauge'::text AS metric_type, '{}'::map[text=>text] AS labels, 1.0::double AS value, 'h'::text AS help (no FROM, or a FROM whose predicate folds to false), then CREATE METRIC SINK s FROM v. The optimizer folds the shaped source to a Constant, so the dataflow has no imports and src/compute/src/render.rs:1330 renders it via to_stream, whose frontier reaches the empty antichain after the initial batch. Both ok_input and err_input close, the operator shuts down, and the guard drops. expire_collection_at would pin a capability at the expiration time and mask this, but it is only applied when dataflow_expiration is set, which it is not for a dataflow with no time dependence.

The same mechanism also means a registration that is still retrying when the input closes never completes: the operator is gone before the next activate_after fires.

Suggested fix: keep the guard's lifetime tied to the collection rather than the operator. Share the PendingRegistration as an Rc<RefCell<_>> between the operator (which fills in handle on a successful retry) and the value returned from render_sink, so it still lands in collection.sink_token. The retry path is unaffected, and the drop point goes back to drop_collection, which is strictly earlier than dataflow drain and so also shortens the window a successor has to retry through.

Comment thread src/compute/src/sink/metric_sink.rs Outdated
Comment on lines 159 to 163
// Drain so the operator isn't rescheduled forever. There is no state to
// fold into on this worker.
ok_input.for_each(|_, _| {});
err_input.for_each(|_, _| {});
return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't have a registration, do we not have to store the data locally so that we can publish it subsequently? Our inputs should be changes to metrics, not the metrics on each tick, so here we'd only start recording metrics once they change, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I 100% understand your concern.

I think there are a few things going on:

  1. The sink reads its source with with_snapshot: true. So my understanding is that it means that it gets the entire current contents at the as_of (as diffs), and then subsequent changes. So there isn't only recording of new changes.

  2. If we don't have a registration, that means we are a none active worker (only one worker receives data in the model). So a non active worker has nothing to store regardless of whether the registration is sucessful or not.

  3. The active worker accumulates regardless of registration outcome. After try_register, we don't have early return. Failed attempts schedules a retry, but execution continues. So the diffs arriving while registration is pending are accumulated in theSinkState. And then a scrape after registration exposes the snapshot

So I don't quite follow where we would miss metrics.

@antiguru antiguru left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a comment; I think we can miss metrics with this approach. Do you mind checking this?

Comment thread src/compute/src/sink/metric_sink.rs Outdated
true
}
Err(prometheus::Error::AlreadyReg) => {
metrics.inc_metric_sink_registration_retry();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we log before retrying instead of after?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nvm, inc_ is the verb here, not _retry. not obvious on the first read through.

@antiguru antiguru left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this against the SQL-680 occurrence in nightly 18362, "Checks 0dt restart of the entire Mz with forced migrations 2" (panic at services.log line 514103). The collision there came from curated sinks. On boot, environmentd reinstalled them under new transient ids (for replica u1, mz_metric_arrangement_sizes went from t291 to t273), and they share the definition-name label, so both incarnations have one Desc id. The registering worker is sink_id.hashed() % peers (metric_sink.rs:73), so old and new usually land on different workers. Worker::reconcile runs per worker without a barrier, so the new one can register before the other worker drops the old handle. User sinks cannot hit this: their durable id keeps both incarnations on one worker, which drops before it re-creates. The retry converges for this race, so it fixes that path.

  1. The concern from def-'s QA review on 09-18 is still open on the current head. The registration guard lives in the operator closure, and the operator has no outputs or capabilities. Once both input frontiers close, for example with a constant-folded source, the closure and the guard drop, and the sink silently stops publishing, taking any in-flight retry with it. Keeping the guard in sink_token, shared with the operator as Rc<RefCell<PendingRegistration>>, still converges and restores unregistering on token drop.
  2. A permanent AlreadyReg collision now retries every second forever, with one info! and an unlabeled counter. Today it soft-panics and CI catches it. Suggest escalating after a bound, e.g. soft_panic_or_log! after 30-60 s. The doc claim that a predecessor always eventually drops is not established. The original SQL-680 report (plain restart, u3 missing, only u4 visible) fits neither this race nor a late drop, so rerunning its minimal repro on this branch would show whether that case is permanent.
  3. The NOTE at metric_sink.rs:90-96, the doc of try_register_collector_with_dropper and the PR description say nothing sequences teardown before render. Within a worker it is sequenced, and the gap is across workers. Naming that, and noting that user sinks are not exposed, would make the contract clear. Transient ids are also reused across boots, so "fresh transient GlobalId" is misleading.
  4. The tests cover the PendingRegistration state machine only. A test that the registration survives the input closing would have caught 1, and a multi-worker reconcile in clusterd-test-driver with a shared label could cover the race.

Posted by Claude Code.

Problem:

A curated metric sink keys its gauges on its stable definition
name, so every incarnation shares one prometheus Desc id. When a
restarted environmentd reconciles against a still-running replica, the
old incarnation and the new one live in the same process, and nothing
sequences the old dataflow's teardown (which drops its registration)
before the new one renders. Registration collided and
register_collector_with_dropper soft-panicked.

Solution:

Add MetricsRegistry::try_register_collector_with_dropper, a
fallible variant that returns the prometheus error, and re-base the
soft-panicking one on it so its callers keep the backstop. The sink
operator owns a PendingRegistration and registers at build time, before
the first activation, so a sink with a slow input still publishes its
series promptly. On a Desc-id collision it retries once a second,
self-scheduling via its activator so a quiescent input still gets
another try. The guard lives in the operator closure, so it drops with
the dataflow, freeing the Desc id for the retrying incarnation.

A new counter, mz_compute_metric_sink_registration_retries_total,
makes retries visible.
@mtabebe
mtabebe force-pushed the ma/metrics/fix-upgrade-test branch from 8051e57 to 3f74b79 Compare September 28, 2026 13:58
@mtabebe
mtabebe requested a review from antiguru September 28, 2026 15:09

@antiguru antiguru left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallible try_register_collector_with_dropper split is clean, and replacing the old "never collides" NOTE is right.

Should fix

  1. Retries aren't rate-limited. try_register runs on every activation of the active worker, and each failure schedules another activate_after(1s). With a busy input during the collision window, that means a registration attempt (collector clone plus a registry write lock) per activation, a pile-up of delayed activations, and a mz_compute_metric_sink_registration_retries_total that counts activations rather than retries, so its help text is misleading. Suggest a next_attempt: Instant on PendingRegistration: return early before it, and only schedule a retry when none is pending.
  2. PR description is stale. It says "The guard lives in the operator closure", but the code deliberately returns it as the sink token, as the comment at the Rc<dyn Any> return explains.
  3. Retries stop if the inputs close first. Timely drops the operator closure once both input frontiers close, which ends the retry loop. The token then holds a PendingRegistration with handle: None forever, and the sink never publishes. That's probably rare for curated sinks, but it contradicts "retried until the predecessor drops". Either note it as a known gap or keep an activation pending until the handle is set.

Nits

  1. "Once per streak" doesn't apply. first_collision and escalated are never reset, and there are no more attempts after success, so there is only ever one streak.
  2. New unchecked claim in the NOTE. "User sinks … both land on one worker that drops before it re-creates, and never collide" is the same kind of claim the old NOTE made, and that one turned out wrong. Point at what enforces it (the ordering in Worker::reconcile) or cover it with a test.
  3. registration_survives_operator_closure_drop only tests Rc refcounting. It doesn't build an operator, so it wouldn't catch the operator no longer sharing the token. A dataflow-level test would, for example a constant-input sink whose series survives its input closing.
  4. Repeated struct literal. The seven-field PendingRegistration { … } literal appears three times. A PendingRegistration::new(label, collector) constructor would remove that.

Generated by Claude Code

@mtabebe
mtabebe force-pushed the ma/metrics/fix-upgrade-test branch from 8a4e327 to d6b071e Compare September 29, 2026 13:22
@mtabebe
mtabebe merged commit b462f89 into MaterializeInc:main Sep 29, 2026
86 checks passed
@def-

def- commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- registration_survives_input_close_in_dataflow passes before timely ever runs, so it never tests operator retirement

src/compute/src/sink/metric_sink.rs:1485

The test's logic closure (move |_frontiers| { input.for_each(..) }) never captures registration_op. Only the build-time constructor uses it, and OperatorBuilder::build calls the constructor right away, so the clone is dropped before worker.dataflow returns. Rc::strong_count(&registration) == 1 therefore holds on the first loop iteration, worker.step() never runs, and the assertion "the operator closure should have dropped once its input closed" is met without the input closing or the operator retiring. The test ends up checking the same Rc refcounting as the test it replaced.

Details

Because the wiring is hand-rolled, the test also can't catch the regression it was written for. If render_sink goes back to returning None and keeping the guard only in its closure (the constant-folded-sink bug from the earlier round), this test still passes.

A version that tests something would move the operator construction into a helper that both render_sink and the test call, with the logic closure borrowing the registration the way production does, and would assert strong_count == 2 after build and before stepping.

This was referenced Oct 2, 2026
val-materialize added a commit that referenced this pull request Oct 9, 2026
Release notes for the v26.45 train, published as **v26.45.1**. v26.45.0
did not ship, so (as with v26.44) its notes are folded in here and
[#39477](#39477) is
closed unmerged.

- **v26.45.0 content**: 8 Improvements, 2 Agent Skills, 8 Bug Fixes,
copied verbatim from #39477 with anchors renamed to `{#v26.45.1-…}`.
- **v26.45.1 content**: Alerting for Self-Managed and the external
dependency monitoring Improvement (editor-added). The release's only PR,
[#39659](#39659), is
omitted.
- **Dates**: Cloud 2026-10-08, Self-Managed 2026-10-09. The operator
compatibility row is `v26.45.1`. `v26.45.md` (`patch: 1`, no `rc`) is
already on `main`.

⚠️ **Open questions**

- **Group-sync bullet**: `oidc_group_role_sync_enabled` defaults to
`false` at the tag. Is it on in Cloud? If not, cut the "Quieter logins"
bullet.
- **Certificate SAN requirements**
([#39316](#39316),
[#39415](https://github.com/MaterializeInc/materialize/pull/39415)/[#39459](https://github.com/MaterializeInc/materialize/pull/39459))
need action at upgrade time but sit in Improvements. Should they be
called out more loudly? Also, does
[#39351](#39351) (S3
on rustls) impose the same SAN rule on custom S3 endpoints?
- **[#39659](#39659)
(omitted)**: is `enable_upsert_paged_spill` on in production? A draft
bullet is in
[omitted.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.45.1/final/omitted.md#borderline).
- **`EXPLAIN ANALYZE ... CPU`**:
[agent-skills#90](MaterializeInc/agent-skills#90)
says it overstates joins, reductions, and TopK by 1.5x–3x before v26.46.
Not published. Is that a known issue?
- **Metric sinks**:
[#39332](#39332),
[#38910](#38910), and
[#39337](#39337) are
withheld because `enable_metric_sink` is `false`. #39337 fixes a panic
any superuser can reach, so consider publishing it regardless.
- **Possible cuts**:
[#39362](#39362) was
observed only in CI. The latency figures from
[#39179](#39179) were
measured on one noisy machine.
- **Possible adds**:
[#38894](https://github.com/MaterializeInc/materialize/pull/38894)–[#38896](https://github.com/MaterializeInc/materialize/pull/38896)
(MV correction buffer) need an end-to-end number.
[#39149](#39149) fixes
the kind install guide, which crash-loops today.
[#39549](#39549)
(jemalloc page) is omitted because no released version had the
regression.

Snapshot drafts in mz-skills:
[v26.45.0](https://github.com/MaterializeInc/mz-skills/tree/main/data/mz-release-notes/v26.45.0)
(rc.1–rc.4, final),
[v26.45.1](https://github.com/MaterializeInc/mz-skills/tree/main/data/mz-release-notes/v26.45.1/final).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Valerie Chiang <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants