Alexander Bij created NIFI-16297:
------------------------------------
Summary: jvm/cluster/connection-analytics Prometheus metrics still
permanently retain a stale pre-cluster-join instance ID series
Key: NIFI-16297
URL: https://issues.apache.org/jira/browse/NIFI-16297
Project: Apache NiFi
Issue Type: Bug
Components: Core Framework
Affects Versions: 2.11.0, 2.10.0
Reporter: Alexander Bij
h2. Summary
NIFI-14014 ("Metrics endpoints may return data for 'dead' instance IDs", fixed
in 2.1.0) described this exact problem: a node's Prometheus \{{instance}} label
starts as a temporary, per-process value and later switches to the node's
persisted cluster UUID once it joins the cluster; any scrape that lands before
that switch leaves a permanent, frozen series behind under the old value.
NIFI-14014's fix (prefer \{{node.getId()}} over
\{{controllerFacade.getInstanceId()}} once available) only changed *which*
identifier is used *after* the transition -- it did not stop metrics from being
recorded under the temporary identifier *before* the transition, and it did not
address the actual root cause: the registries these metrics live in are
long-lived singletons that are never cleared. As a result, the symptom
NIFI-14014 was meant to fix can still occur today.
This affects \{{jvmMetricsRegistry}} (\{{nifi_jvm_*}} metrics),
\{{connectionAnalyticsMetricsRegistry}}, and \{{clusterMetricsRegistry}}
(\{{cluster_*}} metrics) -- every metric in all three registries shares the
exact same \{{instance}} label value, computed once per scrape.
*Simple mental model:* \{{populateFlowMetrics()}} only runs on-demand, in
direct response to an HTTP \{{GET}} on the metrics endpoint -- it is not a
background/scheduled job. So the bug requires a race: it only manifests if
something actually *calls* \{{GET /nifi-api/flow/metrics/prometheus}} during
the narrow window between process start and the node finishing its cluster-join
handshake. If no request lands in that window, the registries stay empty until
the first scrape, which by then already observes the final, stable node UUID,
and no stale series is ever created. If a request *does* land in that window,
whatever \{{instance}} value it observes is written into the singleton registry
and never removed. A typical Prometheus scrape interval (15-60s) makes this a
fairly low-probability race, but any tighter polling against this same endpoint
right at container/process start -- e.g. a Kubernetes readiness or liveness
probe pointed at it -- makes hitting this window far more likely.
h2. Root cause
{code:java}
// StandardNiFiServiceFacade.java, inside populateFlowMetrics()
final NodeIdentifier node = controllerFacade.getNodeId();
final String instId = StringUtils.isEmpty(controllerFacade.getInstanceId()) ?
"" : controllerFacade.getInstanceId();
final String instanceId = node == null ? instId : node.getId();
{code}
* Before a clustered node completes its first connection handshake,
\{{controllerFacade.getNodeId()}} is \{{null}}, so \{{instanceId}} falls back
to \{{controllerFacade.getInstanceId()}} (i.e. \{{FlowController.instanceId}}
-- effectively a standalone-mode value, itself sourced from whichever node is
currently cluster coordinator).
* Once the handshake completes, \{{getNodeId()}} becomes non-null and
\{{instanceId}} permanently switches to the node's *persisted* cluster UUID
(\{{node.getId()}}), which is stable for the rest of that process's life
(stored via local \{{StateManager}} state, unaffected by later
disconnects/reconnects/coordinator elections).
* This one-time transition happens on every node start in a clustered
deployment. Any scrape landing in the (typically brief, but non-zero) window
before the transition permanently registers \{{instance=<temporary-id>}} in
\{{jvmMetricsRegistry}}, \{{connectionAnalyticsMetricsRegistry}}, and
\{{clusterMetricsRegistry}} -- and because none of these three registries are
ever cleared or recreated, that series is never removed. It sits frozen at
whatever value it had during that startup window, returned on every subsequent
scrape for the life of the process, alongside the correct, continuously-updated
series under the real node UUID.
Unlike [connected_nodes churn on cluster_connected_node_count|NIFI-16296]
(fixed by removing a churning label), this leak is bounded to (at most) one
stale series per node process restart, not continuous flapping during
steady-state operation -- but it is real and reproducible on every clustered
node startup, and directly reopens the exact symptom NIFI-14014 was filed to
close.
h2. Why NIFI-14014 didn't fully fix this
NIFI-14014's own description matches this symptom precisely: "If metrics
endpoints are queried before the instance ID transitions, various Gauges (JVM,
cluster, and storage metrics) register with the initial instance identifier.
When the metrics endpoints are subsequently called after the clustered instance
ID is set, the system returns data for both the outdated instance ID and the
new one, creating duplicate metric streams." The fix (PR #9528) only reordered
which identifier source is *preferred*; it never addressed why a stale
identifier's data persists forever once recorded -- i.e. it didn't touch the
singleton/never-cleared registry lifecycle, which is the actual root cause (the
same root cause behind NIFI-8272, NIFI-11899, and NIFI-16296).
h2. Steps to reproduce
# Start (or restart) a NiFi node configured to join a cluster.
# As early as possible during that node's startup -- before it has completed
its initial connect handshake -- scrape \{{GET
/nifi-api/flow/metrics/prometheus}} on that node (a tight polling loop from
just after process start works; a Kubernetes readiness/liveness probe pointed
at this endpoint reproduces this reliably in practice). Observe
\{{nifi_jvm_heap_used}} (or any \{{nifi_jvm_*}}/\{{cluster_*}} metric) reported
under some \{{instance=<uuid-A>}}.
# Wait for the node to finish joining the cluster, then scrape again. Observe
the same metrics now reported under a *different* \{{instance=<uuid-B>}} (the
persisted cluster node UUID).
# Continue scraping indefinitely -- observe that \{{instance=<uuid-A>}}'s
series for every metric in \{{jvmMetricsRegistry}},
\{{connectionAnalyticsMetricsRegistry}}, and \{{clusterMetricsRegistry}}
remains present, frozen at its last-recorded values, for the remaining lifetime
of the process.
h2. Suggested fix
Extend the pattern already established by NIFI-11899 to the three registries
that were left out of it: make \{{jvmMetricsRegistry}},
\{{connectionAnalyticsMetricsRegistry}}, and \{{clusterMetricsRegistry}} fresh,
locally-scoped instances created inside \{{populateFlowMetrics()}} on every
invocation, exactly like \{{nifiMetricsRegistry}} and
\{{bulletinMetricsRegistry}} already are, rather than permanent instance
fields. This is a more general and durable fix than special-casing the startup
window (e.g. suppressing metrics until \{{instanceId}} settles), since it also
protects against any other future source of \{{instance}} (or other label)
churn in these registries, not just this one known transition.
h2. Impact
Any dashboard or alert built on \{{nifi_jvm_*}}, \{{cluster_*}}, or
connection-analytics metrics that aggregates or filters by \{{instance}} can
pick up a permanent, frozen "ghost" series per node restart in a clustered
deployment, in addition to the correct live series -- directly re-opening the
"dead instance ID" symptom NIFI-14014 was filed to eliminate.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)