Replace the per-update expiry check in SwapMeter with a removal counter - #1281
Replace the per-update expiry check in SwapMeter with a removal counter#1281michaelbraun wants to merge 7 commits into
Conversation
SwapMeter.get() runs on the update path of every meter operation and called underlying.hasExpired(), which for AtlasMeter is a wall clock read to evaluate a TTL measured in minutes. Track meter removal in the registry with a plain counter instead, and have get() compare against that. hasExpired() itself is unchanged, so destructive callers such as PolledMeter still only see real expiry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The StepDouble benchmarks moved to StepValueUpdate in spectator-api alongside the rollCount change they measure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The version-only constructor is still used by CompositeRegistry, which has no notion of meter removal. Dropping the underlying.hasExpired() trigger there left its wrappers relying on nested sub registry wrappers for recovery, which works but is a behavior change that has to be argued rather than shown. Keep the original trigger on that path so this change is a no-op for composites, and pin it with a test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comments on the changed paths were carrying explanation the code and the PR description already give. Keep the parts a reader cannot infer (why removal is kept out of hasExpired, why the counter is sampled before the lookup) and drop the rest. Private fields do not need javadoc under this checkstyle config, and @PARAM tags are optional. Three tests asserted an outcome without first establishing the state they describe, so they could have passed without exercising anything: updatesAfterRemovalAreNotLost never checked the meter was removed, expiredButNotYetRemovedMeterKeepsSameInstance never checked it was past its TTL, and cleanupDoesNotBlankOutACompositeRegistry never checked the sweep removed the idle meter. Also renamed versionIsSampledBeforeTheMeterIsResolved to name the removal counter, which is what is actually sampled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // Resolving also clears the staleness hasExpired() reports, since the meter just came from | ||
| // a fresh lookup. | ||
| currentVersion = versionSupplier.getAsLong(); | ||
| underlying = unwrap(lookup()); |
There was a problem hiding this comment.
lookup() goes through the public registry method (registry.counter(id)), which allocates a fresh SwapCounter that unwrap() immediately discards.
That garbage used to be produced only for meters that had actually expired. Now one removal invalidates every outstanding wrapper, so an app holding 50k references produces 50k throwaway wrappers plus 50k normalizeId/normalizeTags passes per cleanup pass.
An internal non-wrapping resolve (getOrCreate directly) would avoid both.
| // Changes when the shape of the registry changes, for example a registry being added to a | ||
| // composite. Feeds hasExpired(). | ||
| private final LongSupplier versionSupplier; | ||
| private volatile long currentVersion; |
There was a problem hiding this comment.
For every wrapper AbstractRegistry hands out, versionSupplier is the constant VERSION (always 0), so currentVersion < versionSupplier.getAsLong() is 0 < 0 forever and the assignment in the resolve branch is a wasted volatile write. Conversely CompositeRegistry never bumps removals, so there currentResolveVersion duplicates currentVersion.
No registry uses both signals, yet SwapMeter now carries three extra fields (LongSupplier ref + volatile long + boolean, ~16 bytes) on the hottest allocation path in the library — registry.counter(id, tags).increment() allocates one wrapper per call. The two version fields look collapsible into one.
|
|
||
| // Set only by the constructor with no removal signal, so registries without one, such as | ||
| // CompositeRegistry, keep the original trigger. | ||
| private final boolean resolveOnUnderlyingExpiry; |
There was a problem hiding this comment.
This flag exists only because CompositeRegistry wasn't migrated, and it puts a second path through get() — a hot path the PR is trying to shorten.
CompositeRegistry already owns an AtomicLong that increments on add/remove (lines 95 and 115); that is its removal signal. Passing it as resolveSupplier, with the version sampled before newCounter(id), would put it on the same path as AbstractRegistry and let you delete this boolean, one of the two version fields, and the five 9-line pass-through constructors added to SwapCounter/SwapTimer/SwapGauge/SwapMaxGauge/SwapDistributionSummary.
| @Override public void remove() { | ||
| delegate.remove(); | ||
| // Bump after the entry is gone, so a reader seeing the new value cannot still find it. | ||
| removals.incrementAndGet(); |
There was a problem hiding this comment.
removals is bumped once per removed meter, not once per cleanup pass, so the cost is not the "extra lookup per held reference per cleanup pass" the field javadoc describes.
AtlasRegistry.removeExpiredMeters() sweeping 5,000 expired meters bumps this 5,000 times over the duration of the loop. Any held reference updated during that window re-reads a higher value on every update, so each increment() runs get() → lookup() → registry.counter(id) → normalizeId + normalizeTags + CHM lookup + a throwaway wrapper, instead of the wall-clock read it replaced. A counter updating at 1M/s across a 10ms sweep does ~10k full lookups rather than one.
Coalescing to a single bump at the end of a pass would restore the documented model.
| private static final LongSupplier VERSION = () -> 0L; | ||
|
|
||
| /** | ||
| * Incremented whenever a meter is removed from {@link #meters}, so the {@link SwapMeter} types |
There was a problem hiding this comment.
{@link SwapMeter} doesn't resolve here — SwapMeter is in com.netflix.spectator.impl and this class doesn't import it. The build won't catch it because the javadoc task sets Xdoclint:none, so it ships as a dead link. Use {@code SwapMeter}, as this same block does two lines down for VERSION.
Naming nit while here: one concept carries three names across the change — VersionedIterator, removals/removalSupplier, and resolveVersion/currentResolveVersion in SwapMeter (plus "the registry version" in the PR description). Picking one term end to end, and naming the iterator after what it does, would remove the need for a couple of the explanatory comments.
| public void setup() { | ||
| // Not started: the publishing scheduler is irrelevant to the write path and would | ||
| // otherwise add background work that shows up as benchmark noise. | ||
| registry = new AtlasRegistry(Clock.SYSTEM, System::getProperty); |
There was a problem hiding this comment.
The registry is never started and removeExpiredMeters() is never called, so removals stays 0 for the whole run and get() always takes the false branch. That means no benchmark here exercises the re-resolve path the change introduces — the headline +75.6% measures only the no-removal steady state, and the per-update re-resolve during a sweep is structurally invisible. lookupCost measures one isolated lookup, which isn't the same thing.
A variant with a background thread calling removeExpiredMeters() at the step interval would show the real steady-state number.
Also: this substantially overlaps BatchUpdates in the same package — same registry construction, and swapCounter/batched/timerRecord re-measure cases it already has. Only atlasCounter, lookupCost, wallTime and perThread are new; folding those four into BatchUpdates would avoid a second file to keep in sync. No @TearDown here either, unlike BatchUpdates.
| * than being kept alive forever by calls that record nothing. | ||
| */ | ||
| @Test | ||
| public void ignoredAmountsDoNotRefreshExpiry() { |
There was a problem hiding this comment.
This one looks unrelated to the PR — it covers AtlasCounter.add()'s pre-existing isFinite/positive guard before updateLastModTime(), and AtlasCounter isn't modified here. It passes identically on main.
Not against the coverage, but landing it in a SwapMeter perf change means a bisect that stops here attributes an AtlasCounter behavior lock-in to this commit, and reverting the perf change would drop coverage unrelated to it. Probably belongs in its own commit or PR.
| * {@code Spectator.globalRegistry()} is — reports expired only when all of its delegates do, so a | ||
| * version based answer would blank out every meter in it after the first cleanup pass. | ||
| */ | ||
| public class SwapMeterExpiryReportingTest { |
There was a problem hiding this comment.
Both new test files are single-threaded, so the concurrency-sensitive part of the change — the check-then-act on currentResolveVersion — had no coverage. removalCounterIsSampledBeforeTheMeterIsResolved uses a getOrCreate hook on one thread, which pins the sampling order in the constructor but not the window in get().
Addressed in 2992479 — I pushed
SwapMeterConcurrentResolveTest, which pins the interleaving (one thread parked insidelookup()while another updates the same wrapper) and fails without the ordering fix.
I did try a load-based variant as well and dropped it: a thread that enters get() before the removal can legitimately write to the old meter in any lock-free design, so "no updates lost under load" isn't a sound assertion. The deterministic test pins the actual invariant instead.
get() wrote currentResolveVersion before the lookup completed, so a concurrent update on the same wrapper saw the new version, skipped the re-resolve and kept writing to the meter the cleanup pass had just removed. That update was silently dropped. Publish the pre-sampled version last: a caller that sees it also sees the meter it describes, and the worst case is one redundant lookup.
The removal counter is global, so any removal invalidates every outstanding SwapMeter, including wrappers holding meters that are still registered. A cleanup pass over N meters therefore makes each of H held references re-resolve up to N times, and each re-resolve is a full registry lookup rather than the wall clock read it replaced. Mark the meter instead. Meter gains an isRemoved() default that falls back to hasExpired(), so registries that do not opt in keep the current behaviour; AtlasMeter overrides it with a flag set by the removal, and the iterator marks each meter it removes. SwapMeter passes the flag through, so a wrapper nested in another, as CompositeRegistry hands out, does not fall back to the clock. A wrapper then re-resolves only when its own meter is gone. This drops the removal counter, the resolve supplier and version, the resolveOnUnderlyingExpiry flag and the five pass-through constructors, and the publish-ordering race in get() cannot occur without a version to publish. CleanupPassCost measures a cleanup pass plus one update through every held reference, in us/op, so lower is better: held/removed main counter flag 100/100 30.5 61.4 36.7 100/1000 91.9 115.6 121.8 1000/100 119.1 195.8 134.1 1000/1000 153.0 209.6 191.7 The counter cost tracks removed x held; the flag cost is roughly 26ns per removed meter, paid once for that meter rather than on every update. CounterIncrement.swapCounter, in ops/s, so higher is better: main 23,176,139 counter 37,105,571 flag 38,419,880
SwapMeter carried two unrelated staleness signals: the meter's own removal flag and a registry version read through a LongSupplier. The version existed only for CompositeRegistry, whose shape changes when a sub-registry is added or removed, so every other registry passed a constant supplier and evaluated 0 < 0 on every update. Express the shape change the same way removal is expressed. A Generation is a single volatile boolean shared by the meters a registry handed out while its shape was unchanged; the composite installs a fresh one and marks the outgoing one stale. Registries with a fixed shape use Generation.PERMANENT, which is never marked. SwapMeter drops the LongSupplier and the volatile long for one volatile Generation, and get() adopts the generation of the meter it resolves so a shape change does not re-resolve on every later update. The AbstractRegistry VERSION constant is gone. This also fixes CompositeRegistry.removeAll(), which installed a new empty shape without bumping the version, so held references never noticed the registries had gone away. Performance is unchanged. CounterIncrement.swapCounter is 39,026,152 ops/s against 39,140,860 before, with the no-wrapper ceiling at 39,781,758, and every CleanupPassCost row is within error of the previous commit.
|
Thanks for digging into this — the steady-state win is real and worth having. I pushed three commits to the branch; the first is a straight bug fix to your design, but the second two replace the removal-counter mechanism, so I want to lay out the reasoning rather than leave you to reverse-engineer it from the diff. Why the counter had to change
That didn't show up in Measured against
So the counter version was up to 2x slower than What replaced itMark the meter instead of counting removals. Steady state is unaffected — if anything slightly better, since there's no shared
For scale, Two follow-on simplificationsThe publish-ordering race. The version field. With removal handled per-meter, the only remaining use of That also fixes One number I can't fully explain: the sweep cost still grows somewhat with |
Summary
SwapMeter.get()runs on the update path of every meter operation and calledunderlying.hasExpired(). ForAtlasMeterthat is a wall clock read on every update, to evaluate a TTL measured in minutes.Track meter removal in the registry with a plain counter instead, and have
get()compare against that.hasExpired()itself is unchanged, so destructive callers such asPolledMeterstill only see real expiry.Why removal is a separate signal from the registry version
SwapMeteralready had aversionSupplierfeedinghasExpired(). Reusing it for removal would be smaller, but it would be wrong: callers treathasExpired() == trueas licence to discard the meter (PolledMeterdrops it,measurements()filters it out,cleanupCachedState()evicts it), and removal happens on every cleanup pass rather than rarely. Folding routine removals into that signal would report healthy meters as expired once per pass.SwapMeterExpiryReportingTest.cleanupDoesNotExpireHealthyMeterspins that down, as doescleanupDoesNotBlankOutACompositeRegistry—CompositeMeterreports expired only when all delegates do, andSpectator.globalRegistry()is one.The counter is registry-global, so any one removal invalidates every outstanding wrapper rather than only the affected one. That costs each held reference one extra map lookup per cleanup pass, which runs once per step interval.
lookupCostbelow is the measurable price.Behavior
For a meter past its TTL but not yet swept, the old and new triggers are equivalent rather than merely close:
lookup()goes throughgetOrCreate, acomputeIfAbsentthat does not replace expired entries, so the old re-resolve returned the same instance already held. The update lands on the same object either way.expiredButNotYetRemovedMeterKeepsSameInstancecovers this.Once the meter is actually swept, the counter moves and the next call re-resolves, which is more timely than waiting for the TTL to be observed.
For registries with no removal signal —
CompositeRegistry, which has no notion of meter removal and still uses the version-only constructor — the originalunderlying.hasExpired()trigger is retained, so this change is a no-op there rather than a behavior change that has to be argued from how nested wrappers surviveunwrap().legacyConstructorStillResolvesOnUnderlyingExpirypins it; stubbing the flag tofalsemakes that test fail.The sampling order matters and is deliberate: the removal counter is read before the meter is resolved. Sampled afterwards, a removal racing the lookup would already be accounted for and the wrapper would stay bound to a meter that is no longer registered, silently dropping every later update. Sampling early can only cost one redundant re-resolution.
versionIsSampledBeforeTheMeterIsResolveddrives a removal in exactly that window.No public API is added: all five
Swap*types are package private, andSwapMeteris inimpland documented as internal.Test plan
SwapMeterExpiryReportingTest— routine removal is not conflated withhasExpired(), composites are not blanked out, the sampling order is correct, and the version-only path keeps its original trigger.AtlasHeldReferenceExpiryTest— held references recover after a sweep, updates through a stale reference are not lost, sweeping one meter does not disturb others, and an expired-but-unswept meter keeps the same instance../gradlew :spectator-api:build :spectator-reg-atlas:buildpasses, including checkstyle, spotbugs and the JDK 17/25/26 test tasks.Benchmark
CounterIncrement, JDK 25, 5 forks x (5 x 1s warmup + 10 x 2s measurement) = 50 measurement iterations, single threaded. Errors are 99.9% confidence intervals including fork-to-fork variance. "Before" ismainwith only the benchmark file added, so both runs measure identical call sequences.swapCounterincrement()through a held referenceperThreadtimerRecordTimer.record()batchedBatchUpdateramortising over 1000 updateswallTimeatlasCounterSwapMeterstripped offlookupCostatlasCounterbypassesSwapMeterentirely, so it should not move, and it does not — that is the negative control for this change. It is worth flagging that in the combined #1277 this benchmark gained 11.8%; all of that belonged to therollCountchange now in #1280, and splitting the two is what made each contribution attributable.perThreadtrackingswapCounterto within 0.1% confirms the gain is per-call path cost and not contention on a shared counter.lookupCostregresses 3.3%, and at these error bars that is a real effect rather than noise. It is the expected trade: constructing a wrapper now samples the removal counter as well and carries one more volatile field. It is paid once per held reference per cleanup pass, against a ~1.75x saving on every single update, but it is a regression and not worth hiding.Retaining the original trigger for the version-only path costs nothing measurable on the hot paths:
swapCounter+0.13%,perThread-0.09%,timerRecord-0.29%, all within noise.