Repository navigation
Conversation
Adds CoordinatorHealthCache, a copy-on-write cache of cluster/per-tablet-server replica and leader health, published lock-free without going through AccessContextEvent -- the same pattern CoordinatorMetadataCache already uses for server topology. The coalescing/urgency mechanics are extracted into a reusable CoalescingRefreshCache<T>, since the same "callers report facts, this decides when to act" shape applies to more than just this one cache. Wired into real coordinator event processing: every mutation that matters (leader/ISR changes, server death/registration, table/partition create-delete, tag add/remove, reassignment) reports through it, and refreshIfNeeded() runs on every event-loop tick, right next to the existing metrics-timer check. Does not wire any RPC to it yet -- getClusterHealth()/describeTabletServers() are untouched and still compute on demand via AccessContextEvent. The rewrite is confirmed mechanically straightforward (CoordinatorService already derives eventManagerSupplier the same way a healthCacheSupplier would) but intentionally left for a follow-up. Related discussion: apache#1389 (comment) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e) method Collapses update()/refreshIfNeeded(compute, idle) into a single refresh(compute, force). The caller now computes "force" itself (typically from queue.isEmpty()) instead of the cache interpreting a separate "idle" signal -- same information, smaller interface. The dirty check is now an unconditional first gate that force never bypasses, only the timing/urgency gate does. This matters: without it, "queue empty -> force=true" would trigger a full rescan on every idle tick regardless of whether anything changed, since a healthy coordinator is idle most of the time. A freshly constructed cache starts dirty so warm-up still works without needing force to bypass the dirty check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
CC @fresh-borzoni if you have some time to review this one related to all others metadata-related PRs 😅 |
fresh-borzoni
left a comment
There was a problem hiding this comment.
@affo
Thanks for the draft. Q: does this answer @LiebingYu concern from #1389?
For reads, yes. O(1) off a volatile field, and the cost no longer depends on how often anyone calls.
For the coordinator loop, I'm less sure. The scan is still with the same time, it just moves from per-call to per-mutation, and force = queue.isEmpty() runs it on every marked event once the queue drains. So loop cost now tracks the mutation rate instead of the call rate. Which one did you expect to be higher? If it's mutations, this adds loop work rather than removing it, and a minimum interval would cap it either way.
The other half is freshness. The marks sit in the event handlers and the activation flip has none, so the snapshot can report GREEN while a leader is still activating. A fixed interval wouldn't be fresher, but it puts a number on staleness and nothing can forget it.
On your objection to periodic in #1389: a stale snapshot shows the leaders still on the server, so a scale-in gate waits instead of proceeding. Isn't that the safe direction?
PTAL
| } | ||
|
|
||
| /** Reports that a bucket's leader became active or inactive. Inactive is urgent. */ | ||
| public void onLeaderActivityChanged(boolean isActive) { |
There was a problem hiding this comment.
While a bucket is pending activation this snapshot still counts its leader as active, so it says GREEN where getClusterHealth says RED.
That's not bounded either: it stays that way until some unrelated event marks dirty.
Mb wire it here, or is the follow-up landing before anything reads the cache?
| public void process(CoordinatorEvent event) { | ||
| if (event instanceof CreateTableEvent) { | ||
| processCreateTable((CreateTableEvent) event); | ||
| healthCache.onTopologyChanged(); |
There was a problem hiding this comment.
Controlled shutdown, retry-offline-leader, resume-drop and the delete-replica response also change health state without a mark.
Would CoordinatorContext's mutators be a better place for this than each handler?
| // sooner only if healthCache itself decided a change was urgent. No AccessContextEvent | ||
| // needed -- this thread already owns coordinatorContext directly. | ||
| if (coordinatorContext != null) { | ||
| healthCache.refresh(coordinatorContext, queue.isEmpty()); |
There was a problem hiding this comment.
queue.isEmpty() isn't a rate limit, it's "refresh unless we're behind", so the recompute rate follows the mutation rate. Can we put a minimum interval on it?
| */ | ||
| public final class ClusterHealthSnapshot { | ||
|
|
||
| public static final ClusterHealthSnapshot EMPTY = |
There was a problem hiding this comment.
EMPTY is all zeros, and the status rule reads all zeros as GREEN. The RPC server starts at CoordinatorServer:314, before the warm-up refresh, so a read served from this cache would answer GREEN on an empty snapshot. Should that case be UNKNOWN instead?
| List<Integer> assignment = ctx.getAssignment(tb); | ||
| numReplicas += assignment.size(); | ||
| // matches CoordinatorService#computeClusterHealth: counts buckets, not leaders | ||
| numLeaderReplicas++; |
There was a problem hiding this comment.
The cluster-wide numLeaderReplicas counts buckets and the per-server one counts leaders, so they don't add up when a bucket has no leader.
Since #3743 will read both from here, worth carrying bucketsWithoutLeader as well?
| // relevant mutation lands. Bulk-loading buckets above does not mark it dirty on purpose | ||
| // (that would mean one dirty-mark per bucket during startup, for no benefit); this single | ||
| // explicit refresh covers it instead. | ||
| healthCache.refresh(coordinatorContext, true); |
There was a problem hiding this comment.
Leader election happens after this warm-up, at :337, so the first snapshot after a coordinator failover is pre-election and already clean. Shall we move the warm-up after it?
Anton's review of apache#4061 found two conflated problems: coverage (five mutation paths never marked the cache dirty) and rate (the queue.isEmpty() signal didn't actually bound recompute frequency under a steady mutation trickle). Separate them cleanly. CoalescingRefreshCache.refresh() drops its force parameter and gains a third, unconditional clause -- an absolute staleness ceiling independent of whether markDirty was ever called -- alongside the existing urgent/normal dirty-driven thresholds. dirty/urgentDirty now only control how fast a known change is reflected, not whether refresh ever runs at all. Also fixes a startup bug this surfaced: lastRefreshTimeMs now seeds in the past so a warm-up call made right after construction is always due, rather than silently no-op'ing because elapsed time was near zero. Coverage moves from scattered healthCache.onXxx() calls sprinkled across CoordinatorEventProcessor's event dispatch into CoordinatorContext's own mutators, via an injected CoordinatorContextListener (implemented directly by CoordinatorHealthCache). Any current or future caller of a hooked mutator is covered automatically. The listener is wired up (and the one warm-up refresh happens) only after bulk load and leader election both complete, so startup reads report an uninitialized snapshot rather than a stale or pre-election one -- ClusterHealthSnapshot gains an isInitialized() flag for exactly this. bucketsWithoutLeader is also now exposed explicitly, reconciling the aggregate/per-server leader-count divergence instead of leaving it a silent discrepancy.
The listener wiring landed a few comments that over-explained the health-cache mechanism inside generic classes (CoordinatorEventProcessor, CoordinatorEventManager) by naming specific unrelated methods and classes. Trim those to what the local code needs, and drop a comment that only explained the absence of code.
|
Thanks for the review @fresh-borzoni — redesigned based on this. Summarizing everything together rather than replying per-thread. In a nutshell: your review found two conflated problems — recompute rate wasn't actually bounded ( Loop cost / rate: you're right that Coverage / freshness: moved dirty-marking out of individual event handlers and into
Periodic vs. event-driven safety (top-level, last paragraph): you're right that staleness is safe here, but it's worth being precise about why, since it's not a general property of staleness — it's specific to the server being scaled in. Once a server is marked shutting down, PTAL. |
|
@affo thanks, everything from the first round is fixed, and putting the hooks in CoordinatorContext's mutators works really well. I've been thinking about the tradeoff again, and I think my last review sent you the wrong way. I asked for a minimum interval, and that's where the timers came from. Looking at who actually calls these APIs, I'm not sure caching pays off:
So what if #3743 computes the per-server and cluster numbers together in one pass per call? Then the operator gets one consistent view for its gate. We could park this PR until something needs to read health often. If that happens, running totals kept in the mutators, the way offlineBucketCount works, would be cheaper and fresher than timed rescans. @morazow, that would mean you don't need to wait for this one. If we do keep this version, two bugs need fixing first:
@LiebingYu @swuferhong, does this match how you see it? |
|
@fresh-borzoni sorry for the latency, I do agree on your view. This unblocks the describeTabletServers 🤝 |
Summary
Companion to the discussion on #1389 about splitting
getServerNodes()/getClusterHealth()/describeTabletServers()by cost and update frequency (comment).Adds
CoordinatorHealthCache: a lock-free, coordinator-thread-published snapshot of cluster-wide and per-tablet-server replica/leader health, kept warm incrementally as mutations happen, instead of recomputed from scratch on every RPC call viaAccessContextEvent. Wired into real mutation handling (leader/ISR changes, server death/registration, topology changes). No RPC reads from it yet —getClusterHealth()/describeTabletServers()are untouched; this PR is the mechanism only.Performance & consistency trade-offs
getClusterHealth()/describeTabletServers()each enqueue a freshO(buckets)scan onto the single coordinator event thread on every call. This cache makes readsO(1)— a lock-free field read — by moving the scan to the write side, where it's decoupled from call frequency and triggered by mutation instead.O(buckets)per recompute, unavoidably — the point isn't to make the scan cheaper, it's to make it run less often.Every mutation is coalesced: a burst of many changes (e.g. a full failover storm re-electing leaders for hundreds of buckets) costs at most one recompute, not one per event, bounded by how long the coordinator's event queue stays busy.Correction: recompute frequency is no longer tied to queue state at all (
queue.isEmpty()tracked mutation rate, not a fixed interval, so loop cost had no real ceiling under a steady mutation trickle). It's now gated purely on elapsed wall-clock time: at most once per 200ms for urgent changes, once per 3s for anything else, regardless of how many mutations land in between. A burst of many changes still costs at most one recompute, but the bound is time-based, not queue-based.Consistency is bounded, and deliberately asymmetric: changes that represent degradation (a server dying, a bucket going under-replicated, a leader missing or inactive) are guaranteed to be reflected within 200ms regardless of how busy the coordinator is; everything else (topology changes, tag updates, healthy ISR churn) is only guaranteed eventually, once the event queue next drains. This is safe specifically because staleness always lags toward the previous known state — a stale read can show a server as busier/healthier than it now is (bounded to 200ms of lag for degradation), but never masks a live problem for longer than that bound, and a consumer using this as a scale-in/rolling-upgrade safety gate can only end up waiting longer than strictly necessary, never acting on data that's wrong in the unsafe direction.Correction: consistency is bounded by two independent mechanisms now, not one. Rate: degradation (server death, under-replication, missing/inactive leader) is reflected within 200ms; everything else within 3s — both measured from elapsed time, not queue drain. Coverage: an unconditional 10s ceiling bounds staleness regardless of
dirtyat all, so a mutation path nobody remembered to instrument is still caught within a fixed bound instead of drifting indefinitely. On the safety claim: "staleness lags toward the previous state, so it's always safe" isn't a general property of this cache — it depends on the specific consumer. For a scale-in gate specifically, it holds only for the server already marked shutting down:TableBucketStateMachine.electLeaderexcludes shutting-down servers fromControlledShutdownLeaderElection, so that server's leader count can only go down once marked, and a stale read can only over-report leaders still present there, never invent one. That's not true in general — before a server is marked shutting down, or for any other server, a stale read absolutely can under-report a leader that just landed.Impact on other open PRs
DescribeTabletServers) — once merged, can read this cache directly (it already carries the exact per-server counters that RPC needs, plusbucketsWithoutLeader()reconciling the aggregate/per-server leader-count divergence) instead of its ownAccessContextEventscan. No new snapshot shape required.getServerNodes/ server tags) — no direct relationship, but confirms the same pattern (this PR'sCoalescingRefreshCache, or the existingCoordinatorMetadataCache) is the right fix for that PR's livezkClient.getServerTags()call, which is doing a ZK round-trip on every call for data that's already cached elsewhere.DescribeBuckets) — same opportunity, but two steps: first stop bypassingCoordinatorContextfor a live ZK read, then a per-bucket cache shaped like this one.UpdateMetadataRequestpropagation) — no relationship, and none expected: that's a coordinator-initiated push to tablet servers, not a client read blocked on the event thread, so this mechanism doesn't apply there.BucketMetadata.javaextensions — this PR doesn't touch that class, so no merge conflict risk from this change.Known, deliberate gap: leader-activation flips (CoordinatorRequestBatch) aren't wired to this cache yet — bounded staleness, not a correctness issue (the next refresh for any other reason reports it correctly regardless), left for a follow-up.Resolved: leader-activation flips are now wired directly —
CoordinatorContext.addPendingLeaderActivation/clearPendingLeaderActivationnotify the listener as part of the same mutator-level hookup that closes the other coverage gaps below.Test Plan
New unit tests:CoordinatorHealthCacheTest(15),CoalescingRefreshCacheTest(6).Updated:
CoordinatorHealthCacheTest(16),CoalescingRefreshCacheTest(8) — rewritten for the elapsed-time-gatedrefresh()API, including a case proving the safety-net ceiling fires even ifmarkDirtyis never called.CoordinatorEventProcessorTest,ClusterHealthTest,CoordinatorEventManagerTest,TableBucketStateMachineTest,CoordinatorContextTest,CoordinatorMetadataCacheTest— 95 tests total across both runs.mvn spotless:checkclean.🤖 AI-assisted changes - reviewed by human developer