Repository navigation
prometheus: redesign discovery (singleflight + candidate pruning + reachability probing) - #1153
Merged
Merged
Conversation
nadaverell
force-pushed
the
prometheus-discovery-dedupe-parallel-probes
branch
2 times, most recently
from
July 12, 2026 15:18
3dce4b1 to
ab20cb8
Compare
nadaverell
force-pushed
the
prometheus-discovery-dedupe-parallel-probes
branch
from
July 12, 2026 15:33
ab20cb8 to
63f849d
Compare
nadaverell
force-pushed
the
prometheus-discovery-dedupe-parallel-probes
branch
from
July 12, 2026 15:48
63f849d to
8e54a99
Compare
nadaverell
force-pushed
the
prometheus-discovery-dedupe-parallel-probes
branch
from
July 12, 2026 16:01
8e54a99 to
04bfd5b
Compare
nadaverell
force-pushed
the
prometheus-discovery-dedupe-parallel-probes
branch
from
July 12, 2026 16:12
04bfd5b to
1162299
Compare
nadaverell
force-pushed
the
prometheus-discovery-dedupe-parallel-probes
branch
from
July 12, 2026 19:24
1162299 to
0e90a47
Compare
nadaverell
force-pushed
the
prometheus-discovery-dedupe-parallel-probes
branch
from
July 12, 2026 19:40
0e90a47 to
041e096
Compare
nadaverell
force-pushed
the
prometheus-discovery-dedupe-parallel-probes
branch
from
July 12, 2026 19:54
041e096 to
7e5e7b7
Compare
On a cluster with several Prometheus-like services, concurrent cost/traffic requests each ran their own discovery and fought over the process-global port-forward singleton, churning for 20-30s and often failing to connect. Reworks discovery end to end; verified against a live GKE cluster (~24s-and-fail â ~6s-and-connect). Coalesce discovery. Concurrent EnsureConnected callers share one discovery via singleflight; the shared run detaches from the leader's request context so it completes for every waiter, while each caller selects on its own context and returns promptly when cancelled. A discovery generation (bumped by Reset / SetManualURL / SetHeaders) keys the flight and gates the connection write, so a run started under an old configuration can't publish a stale endpoint; markConnected reports whether it committed, so a superseded run surfaces a retryable error instead of a hollow success, and Set*/Reset cancel the in-flight run so it releases the gate promptly. A process-global gate serializes discovery across Client instances too â a client reinit on context setup can leave the startup pre-warm and the first request discovering at once, and only a global gate keeps them off the shared port-forward; Reinitialize both cancels and retires the outgoing client so even a flight that hadn't started yet aborts instead of driving the forward for a replaced context. Admit by identity, rank by score, gate by probe. Services we're certain can't answer PromQL â node-exporter, kube-state-metrics, the operator, alertmanager, pushgateway, grafana, blackbox, Thanos non-query components, control-plane scrape targets (kubelet, kube-etcd, âĶ) â are excluded by identity. A candidate is admitted only on a Prometheus-family identity signal (a name/label/port match); the heuristic score merely ranks. Namespace membership alone does not admit â discovery probes candidates with the operator's configured Prometheus headers, so an unrelated neighbor must not be a candidate just for sharing a namespace â and a lightly-labeled real Prometheus is kept and validated by the probe rather than dropped by a score floor. Candidates found by both the well-known list and the dynamic scan are de-duplicated. Enumerate and probe faster. Well-known locations are fetched concurrently (bounded) instead of ~20 serial Gets â the dominant latency against a remote API server â assembled in declared priority order, still via targeted Gets so get-without-list callers keep working, and logging stays serial to honor the Logger callback contract. Direct in-cluster probing runs for every environment (it connects in-cluster and over networks that route Service DNS, e.g. VPN), launched in priority order under a budget larger than the per-probe timeout so a slow-but-reachable endpoint gets a full attempt while an unresolvable cluster-DNS name â which can wedge in the platform cgo resolver past its deadline â can't stall the pass; a candidate that already answered is never dropped on budget expiry, even if its completion was still buffered. Port-forwarding stays the serial fallback. Rework the log trail. One line lists the candidates; the direct pass summarizes in a single line instead of one rejection per candidate; each phase (enumerate / direct probe / total) carries its own elapsed time; a run that ends on a context error is logged as superseded or timed-out rather than as a failure.
nadaverell
force-pushed
the
prometheus-discovery-dedupe-parallel-probes
branch
from
July 12, 2026 20:00
7e5e7b7 to
b047415
Compare
âĶt clobber The metrics port-forward was a process-global singleton: one active tunnel, started/stopped by both prometheus discovery and the traffic subsystem (Caretta/Hubble). When they wanted different services concurrently, each Start tore down the other's forward. Hold one forward per owner (OwnerPrometheus / OwnerTraffic) instead. Start replaces only the calling owner's forward; Stop stops only theirs. Owners may still read each other's forward address (GetAddress) to reuse a compatible endpoint, but never stop it â read-only. Since traffic's Reset no longer stops the singleton (and thus incidentally prometheus's forward), prometheus's Reset now tears down its own forward on context switch / config change. Traffic connection status reads the live registry (its own forward, else any active forward for the context it may be reusing) rather than caching a snapshot that could report a stopped forward as connected. This is the per-subsystem fix for cross-owner clobbering; per-prometheus coalescing (singleflight + discovery gate) and the config/context-switch client-state guards are unchanged.
Map iteration made reuse selection nondeterministic when both owners hold a forward for the context: prometheus rediscovery could be handed Hubble's relay instead of its own live forward, fail the probe, and fall through to a full rediscovery. GetAddress now returns the caller's own forward first, then a peer's â deterministic, and never skips a live own forward.
A discovery superseded (Reset/Reinitialize) mid-fallback kept iterating the candidate list, holding the discovery gate, so a newer discovery waited. Check ctx before each port-forward attempt and return superseded immediately.
âĶresh read discoverShared keyed the singleflight on the generation but discover re-read the live discoveryGen as startGen. A flight keyed before a Reset could start after it, read the bumped generation, and markConnected under it â undoing the reset and holding the discovery gate. Thread the flight's generation into discover so markConnected checks against it.
The discovered bool was set on connect/reset but never read for any decision â connection status keys off baseURL. Remove the dead state.
GetConnectionInfo fell back to any owner's forward for the context, so traffic showed connected using Prometheus's forward even when traffic never adopted it (e.g. after a failed Hubble connect). Traffic's data path always uses its own forward, so a peer's forward means the peer is connected, not traffic. Drops the now-unused GetConnectionInfoForContext.
A config change or context switch mid-discovery cancels the run; the manual-URL probe and candidate enumeration reported that as "URL not reachable" / "no Prometheus found" in errorlog instead of recognizing supersession, unlike the later direct-probe and port-forward paths. Add the same ctx.Err() guard so the diagnostics stay honest.
âĶsupersedes a run When Reset/SetManualURL/SetHeaders cancelled an in-flight discovery, a waiter whose own request context was still live received the supersession error and surfaced it (opencost reported Prometheus unavailable for an otherwise-healthy request). discoverShared now retries under the fresh generation while the caller's context is live; a retired client still returns promptly rather than retrying, since retirement is permanent.
The port-forward fallback loop recorded 'port-forward failed' / 'not responding' in errorlog for the in-flight candidate when a config change or context switch cancelled the run mid-attempt. Check ctx.Err() after Start and after the probe so supersession is surfaced, matching the guard the loop's other paths already have.
âĶ ready wait Start held reg.mu for its whole body, including findPodForService and the up-to-10s wait for the forward to become ready. That blocked every other registry operation â the other owner's Start, Stop, GetAddress, and traffic's status reads â for the duration, and serialized the two owners' forwards that the per-owner split exists to keep independent. Hold reg.mu only for the short slot/field access and establish the forward without it; a per-owner establishMu serializes same-owner Starts so a race can't leak a forward goroutine.
| info := f.info() | ||
| reg.mu.Unlock() | ||
| log.Printf("[portforward] Ready: localhost:%d -> %s/%s:%d (owner=%s, context: %s)", | ||
| localPort, namespace, serviceName, targetPort, owner, contextName) |
âĶd-establish Narrowing Start's lock scope opened a window: while a forward was still coming up (not yet active), a concurrent Stop â e.g. a context-switch Reset â no-op'd, then Start committed the forward anyway, leaving a connection to the old context reported as live. Bump a per-owner epoch on every teardown (including when the forward is only establishing) and have Start refuse to publish if the epoch moved while it was establishing.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
â Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit c4dd177. Configure here.
âĶ it published its cancel A worker keyed at one generation could reach its cancel-publish point after a Reset/SetManualURL/SetHeaders had already bumped the generation and called cancel() â a no-op while the handle was still nil. It would then hold the discovery gate and a live detached context for up to discoveryTimeout, blocking rediscovery under the new configuration. Abort at publish when the generation no longer matches, alongside the existing retirement check.
This was referenced Sep 13, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Ground-up fix for slow and failing Prometheus discovery on cold start. On a cluster with several Prometheus-like services, concurrent cost/traffic requests each ran their own discovery and fought over the process-global port-forward singleton â churning for 20â30s and often failing to connect. Verified against a live GKE cluster: ~24s-and-fail â ~6s-and-connect.
What changed
Coalesce discovery
EnsureConnectedcallers share one discovery via singleflight; the shared run detaches from the leader's request context so it completes for every waiter, while each callerselects on its own context and returns promptly when cancelled.Reset/SetManualURL/SetHeaders) keys the flight and gates the connection write;markConnectedreports whether it committed, so a run superseded by a config change surfaces a retryable error instead of a hollow success (address returned butbaseURLempty).Clientinstances too â a client reinit on context setup can leave the startup pre-warm and the first request discovering at once, and only a global gate keeps them off the shared port-forward. It's context-aware, andReset/Reinitializecancel the in-flight run so a superseded discovery releases the gate promptly.Prune candidates
Non-query services (node-exporter, kube-state-metrics, operator, alertmanager, pushgateway, grafana, blackbox, Thanos non-query, control-plane scrape targets) are excluded, and a minimum dynamic score drops bare name matches (kubelet, otel-collector). Scoring credits a recognized container
targetPort(not just the service port), so a real Prometheus behind a port-80 service still qualifies. Duplicates across the well-known list and dynamic scan are de-duplicated.Enumerate & probe faster
Well-known locations are fetched concurrently (bounded) instead of ~20 serial Gets â the dominant latency against a remote API server â assembled in priority order, still via targeted Gets (get-without-list callers keep working), logging serially to honor the
Loggercontract. Direct probing runs for every environment (connects in-cluster and over networks that route Service DNS, e.g. VPN), bounded by a budget larger than the per-probe timeout so a slow-but-reachable endpoint gets a full attempt while an unresolvable cluster-DNS name (which can wedge in the platform cgo resolver past its deadline) can't stall the pass. Port-forwarding stays the serial fallback.Log trail
One line lists the candidates; the direct pass summarizes in one line (not one rejection per candidate); each phase (enumerate / direct probe / total) carries its own elapsed time; a superseded run is logged as such, not as a failure.
Tests
pkg/prom: de-dup, scoring disqualifiers, control-plane exclusion, unlabeled-custom-prometheus kept, concurrency-safe Logger.internal/prometheus: singleflight coalescing, prompt cancellation, stale-generation gate,Resetcancels in-flight discovery, batch respects its budget, probe ordering. Race-clean under-race.Port-forward ownership (folded in)
The metrics port-forward was a process-global singleton shared by prometheus discovery and the traffic subsystem (Caretta/Hubble); when they wanted different services concurrently they clobbered each other. Now each subsystem owns its own metrics forward (
OwnerPrometheus/OwnerTraffic) âStart/Stoponly ever touch the caller's forward, cross-owner reuse stays read-only, and traffic connection status reads the live registry.Deferred follow-up
Note
Medium Risk
Touches shared connection discovery, port-forward lifecycle, and context-switch cancellation paths; behavior is complex but heavily covered by new tests.
Overview
Fixes cold-start Prometheus discovery where parallel
EnsureConnectedcallers and prometheus/traffic both fought over one metrics port-forward, often failing after long churn.Discovery coalescing & lifecycle: Concurrent discovery is merged with singleflight and a process discovery gate, keyed by a discovery generation bumped on
Reset/SetManualURL/SetHeaders. Detached runs can be cancelled on context switch; stale results are dropped viamarkConnected/connectionLiveinstead of hollow successes. Direct candidate probing is bounded and concurrent (priority-ordered, time-budgeted) before serial port-forward fallback.Candidate quality & speed:
pkg/prom.Discoverexcludes non-query metrics services, requires a Prometheus identity signal (not namespace-only neighbors), de-duplicates well-known vs dynamic hits, and fetches well-known Services in parallel. Quieter, phase-timed discovery logs.Port-forward ownership: Replaces the global metrics forward with per-owner forwards (
OwnerPrometheus/OwnerTraffic);Stoponly tears down the callerâs tunnel, with epoch guards ifStopraces in-flightStart. PrometheusResetexplicitly stops its forward; traffic/Caretta/Hubble passOwnerTraffic.Reviewed by Cursor Bugbot for commit 2ecb582. Bugbot is set up for automated code reviews on this repo. Configure here.