Skip to content

Add AbstractMetrics#removeTableMetrics to drop every metric series for a table - #19503

Open
jineshparakh wants to merge 1 commit into
apache:masterfrom
jineshparakh:table-metric-removal-spi
Open

Add AbstractMetrics#removeTableMetrics to drop every metric series for a table#19503
jineshparakh wants to merge 1 commit into
apache:masterfrom
jineshparakh:table-metric-removal-spi

Conversation

@jineshparakh

Copy link
Copy Markdown
Collaborator

Intent

Delete a table and its metric series keep being exported by the brokers, servers and controllers that
were serving it — frozen at whatever value they last held — until each of those processes is restarted.

That is not merely untidy:

  • Dashboards lie. A panel filtered by table shows a row for a table that no longer exists, holding
    its final measurement forever.
  • Alerts fire on ghosts. Gauges such as percentOfReplicas, segmentsInErrorState or
    percentSegmentsAvailable keep their last value. If a table was degraded at the moment it was
    dropped — which is common, since deletion drives replicas offline — an alert can fire after the
    table is gone and never clear.
  • Cardinality only grows. In a cluster where tables are created and dropped routinely, the metric
    registry, the JMX MBean set and everything downstream of it accumulate monotonically for the life of
    the process.

The goal is that deleting a table stops its metrics, everywhere, without waiting for a restart.

This PR does not do that on its own. It adds the one capability all three components need, and the one
SPI accessor that capability requires. It is deliberately behaviour-neutral — nothing calls it yet —
so that the API can be discussed separately from the three behaviour changes that will build on it.

Why the cleanup we have today doesn't achieve it

Removal is retrofitted per component, and every implementation reconstructs metric names by replaying
the composition rules in AbstractMetrics. Reconstruction can only reach a name that is fully
derivable from the table name
. Several names are not, so we have three hand-rolled sweeps, each
incomplete in a different way: the controller and server iterate their metric enums and so miss
anything carrying an extra segment between the table and the metric name, and the broker removes three
gauges and nothing else — no query counters, no phase timings, no time boundary, no quota gauges.

Two current examples of the blind spot:

tableRebalanceExecutionTimeMs is registered as <table>.<jobStatus>.<timer>, because
TableRebalancer passes a synthetic table name. The sweep composes <table>.<timer>. It never matches,
for any of the seven RebalanceResult.Status values. cronSchedulerJobExecutionTimeMs has the same
shape with a task type in place of the status.

OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT is registered as <gauge>.<table>.<column>$<key>. The
server's deletion handler already documents this exact failure, works around it by recovering keys from
the table config, and concedes in its own javadoc that the workaround is partial: "Gauges for
discovered keys survive until the server restarts."

Both are the same defect. Emission is modelled; removal is not. Every new keyed metric silently
re-opens the hole, and nothing tells the author who added it.

What this PR adds

A way to remove a table's series based on what is actually registered, rather than on names we can
reconstruct — so keyed, composite and future name shapes are all reachable without anyone having to
remember them.

Doing that needs one thing from the SPI: the ability to read a registered metric's name back as a
string. PinotMetricName exposes only Object getMetricName(), and toString() is not portable —
yammer renders a JMX object name there, dropwizard renders the bare name. So the PR adds a single
default String getName() and overrides it in the in-tree implementations. As a default method it is
source- and binary-compatible: a third-party metrics plugin keeps working, and at worst the sweep
matches nothing for it, which is exactly today's behaviour.

Arguably this accessor is one the interface should have had regardless; the sweep is just its first
consumer.

The flow this unlocks

The follow-up PRs attach the sweep to each component's existing table-deletion signal — no new
messages, no new protocol, no new ZooKeeper writes.

                              DELETE /tables/{table}
                                        │
                        PinotHelixResourceManager.deleteTable
                                        │
   ┌────────────────────────────────────┼────────────────────────────────────┐
   │ ① removed from brokerResource      │ ② TableDeletionMessage             │ ③ table-config znode
   │    (happens FIRST)                 │    → every server                  │    removed (LAST)
   ▼                                    ▼                                    ▼
BROKER                               SERVER                              CONTROLLER (every one)
Helix ONLINE→OFFLINE/DROPPED         Helix USER_DEFINE_MSG                ZK child watch /CONFIGS/TABLE
   │                                    │                                    │
   ▼                                    ▼                                    ▼
BaseBrokerRoutingManager             SegmentMessageHandlerFactory         (new) TableMetricsCleaner
  .removeRoutingInternal              .TableDeletionMessageHandler
   │                                    │                                    │
   └────────────────┬───────────────────┴────────────────────────────────────┘
                    ▼
        AbstractMetrics.removeTableMetrics(...)      ← this PR

Two ordering facts the follow-ups have to respect, recorded here because they are not obvious and each
cost a debugging cycle to find:

  • The table config is removed last. A "does the table still exist in ZooKeeper?" guard is therefore
    useless at broker and server time — the config is still present when the deletion becomes observable
    there. Only the controller's watch can use the ZK snapshot that reported the deletion, which is
    authoritative at that instant.
  • Some series key off the raw table name, not the name with type — the deep store timers and byte
    counters, and the broker's whole query path. Those are shared by the OFFLINE and REALTIME halves of a
    hybrid table, so they may only be swept once neither half remains.

Scope note: this is deletion-driven. A table that still exists but has stopped being reported on —
disabled, ideal state unreadable, or leadership moved — remains SegmentStatusChecker's business, since
only it knows it stopped reporting.

Approaches considered and rejected

Fix the two known offenders. Add a keyed timer remover and loop over RebalanceResult.Status, then
do the same for the cron timer. Rejected as symptom-level: it leaves the defect class intact, so the
next keyed metric leaks again and the person adding it gets no signal.

Extend the enum-driven sweep properly — add an abstract getTimers() to AbstractMetrics, add
keyed removeTableTimer / removeTableMeter overloads, and have each component iterate every key it
knows about. This was the first design. Rejected because it inherits the very defect it is fixing: it
can still only remove names it can reconstruct, so it is complete only for as long as someone keeps it
in sync with every emission site. It also makes getTimers() a compile break for every subclass.

Clean up inside PinotHelixResourceManager#deleteTable. Superficially the obvious place. Rejected
because deletion happens in one process while the series live in nearly all of them: that controller
holds one metric registry out of N and cannot reach any broker's, any server's, or any other
controller's. It is also edge-triggered in the worst way — a missed call leaks forever, and it races a
delete-then-recreate.

Parse toString() instead of adding an SPI method. Rejected: the format is implementation-specific
(a JMX object name on yammer, the bare name on dropwizard), so this would mean implementation-specific
parsing inside a generic base class, silently wrong for any plugin we do not know about.

Reach the name reflectively through the existing Object getMetricName(). Technically workable for
the in-tree implementations. Rejected as an undocumented contract that would break silently rather than
at compile time.

Have AbstractMetrics index the names it registers and sweep that index, avoiding the SPI change
entirely. Genuinely attractive — ownership becomes exact by construction and sweeps get cheaper, since
allMetrics() materialises a fresh map on some implementations. Rejected on hot-path cost: it puts a
map write on every metric emission, and addMeteredTableValue runs several times per query, to save one
default method. It also introduces a second source of truth that can drift from the registry. The
registry scan pays its cost only on deletion, which is rare and operator-driven.

Backward compatibility

  • No wire surface. PinotMetricName is confined to the metrics packages, is not Serializable, and
    appears in no message class, ZooKeeper znode or request/response type. Metric names leave a process
    only as JMX MBeans.
  • No emitted name changes, so every exporter rule, dashboard and alert keeps matching exactly what
    it matched before.
  • default method, so third-party metrics plugins remain source- and binary-compatible.
  • Mixed versions are independent. Each component has its own JVM, registry and copy of
    pinot-common; there is no shared metric state and nothing to negotiate. In a partially upgraded
    cluster each component simply behaves per its own version.
  • No behaviour change at all in this PR — nothing calls the new method yet.

Testing

AbstractMetricsTest is abstract with three concrete subclasses, so the new cases run against the
fake, yammer and dropwizard registries. They cover the names reconstruction cannot reach
(including a keyed timer registered exactly as TableRebalancer emits it), that a swept gauge can be
re-registered afterwards, whole-segment matching so neighbouring and database-qualified tables are not
caught, that global series and other tables survive, that the shared allTables aggregate cannot be
deleted, and that a second AbstractMetrics sharing the same registry and prefix keeps its gauges.

Pinot SPI ................ SUCCESS
Pinot Common ............. SUCCESS    46 tests
Pinot Yammer Metrics ..... SUCCESS    20 tests
Pinot Dropwizard Metrics . SUCCESS    20 tests
Pinot Compound Metrics ... SUCCESS
checkstyle:check + license:check ... clean

Signed-off-by: Jinesh Parakh <jineshparakh@hotmail.com>
@jineshparakh jineshparakh added enhancement Improvement to existing functionality metrics Related to metrics emission and collection labels Sep 8, 2026
@codecov-commenter

codecov-commenter commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.81818% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.72%. Comparing base (bb44811) to head (7be1697).
⚠️ Report is 29 commits behind head on master.

Files with missing lines Patch % Lines
...g/apache/pinot/common/metrics/AbstractMetrics.java 86.20% 0 Missing and 4 partials ⚠️
...ugin/metrics/compound/CompoundPinotMetricName.java 0.00% 1 Missing ⚠️
.../org/apache/pinot/spi/metrics/PinotMetricName.java 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19503      +/-   ##
============================================
+ Coverage     67.59%   67.72%   +0.13%     
- Complexity     1430     1431       +1     
============================================
  Files          3487     3490       +3     
  Lines        224320   224666     +346     
  Branches      35417    35476      +59     
============================================
+ Hits         151623   152166     +543     
+ Misses        60679    60464     -215     
- Partials      12018    12036      +18     
Flag Coverage Δ
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 67.72% <81.81%> (+0.13%) ⬆️
lane-a 100.00% <ø> (ø)
lane-b 0.00% <ø> (ø)
temurin 67.72% <81.81%> (+0.13%) ⬆️
unittests 67.72% <81.81%> (+0.13%) ⬆️
unittests1 57.78% <83.87%> (+0.07%) ⬆️
unittests2 39.45% <3.03%> (+0.11%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@xiangfu0 xiangfu0 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed this head across the eight Pinot review domains. Three substantive issues are detailed inline and were reproduced locally. The 60 shared AbstractMetrics tests passed across fake, Yammer, and Dropwizard registries; compound metrics compiled successfully. The issues affect the new cleanup API when invoked; this PR adds no production callers.

int start = firstDot > 0 && gaugeNames.contains(name.substring(0, firstDot)) ? firstDot + 1 : 0;
for (String tableName : tableNames) {
int end = start + tableName.length();
if (name.startsWith(tableName, start) && (end == name.length() || name.charAt(end) == '.')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2 / MAJOR] Preserve exact table identity when matching registered names

The delimiter and gauge-name heuristics can delete unrelated metrics as well as miss the requested table. I reproduced these cases against the compiled PR implementation:

  • Register a meter and gauge for db.foo_OFFLINE, then call removeTableMetrics("db"): both are removed, although they belong to a different, database-qualified table.
  • Register ControllerGauge.REALTIME_TABLE_COUNT, then sweep realtimeTableCount: the global gauge is removed.
  • Register a meter for numberOfReplicas.foo_OFFLINE: sweeping that table removes nothing, while sweeping unrelated foo_OFFLINE removes the meter because the database name is interpreted as a gauge name.

The API explicitly accepts raw table names, so these inputs are within its contract. Please preserve table identity and metric scope at registration, or otherwise make matching unambiguous, and add regression coverage for these collisions. A dot boundary alone cannot distinguish database qualification from metric suffixes.

}
// Re-deriving the key under this class is the ownership test: an identically named series registered by a
// sibling AbstractMetrics is a different key, so it compares unequal and is left for that instance to sweep.
if (registeredName.equals(PinotMetricUtils.makePinotMetricName(_clazz, name))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2 / MAJOR] Preserve ValidationMetrics ownership on Dropwizard

DropwizardMetricName discards the owning class and compares only the name string, so re-deriving the key with _clazz does not establish ownership on that backend. With a shared Dropwizard registry, I reproduced:

  1. ValidationMetrics.updateMissingSegmentCountGauge("foo_OFFLINE", 1) registers one gauge.
  2. ControllerMetrics.removeTableMetrics("foo_OFFLINE") removes it.
  3. Updating the validation gauge again leaves the registry empty: ValidationMetrics still has its private _gaugeValues entry and skips re-registration.

This contradicts the documented exclusion of ValidationMetrics and can leave its series absent across subsequent updates. Please preserve ownership independently of backend key equality, or explicitly exclude these foreign registrations, with a Dropwizard regression that verifies the validation gauge survives and remains updateable.

// gauge from ever coming back. Swept from this instance's own map rather than from what matched above, so it
// stays correct even where the registry cannot tell two instances' series apart.
synchronized (_gaugeValues) {
_gaugeValues.keySet().removeIf(gaugeName -> matchesAnyTable(gaugeName, targets, gaugeNames));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2 / MAJOR] Keep registry removal and gauge-value cleanup atomic

A gauge can be registered after the registry snapshot above but before this removeIf. It is then absent from the removal snapshot, so its registry entry survives, while this code deletes its backing _gaugeValues entry. The supplier installed by setValueOfGauge calls _gaugeValues.get(gaugeName).get() and throws NullPointerException on scrape until another update.

I reproduced this deterministically with latches around snapshot/registration: the sweep returned 0, the registry retained one gauge, its backing value was null, and reading the gauge threw at AbstractMetrics.java:436.

Please coordinate the snapshot, registry removal, and backing-value cleanup with the same monitor used by gauge registration, as the existing targeted removeGauge does for its two removals. Add a barrier-controlled regression for registration during the sweep.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improvement to existing functionality metrics Related to metrics emission and collection

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants