Rework server-side per-key coordination with a non-blocking scheduler#206
Rework server-side per-key coordination with a non-blocking scheduler#206diegosalvi wants to merge 7 commits into
Conversation
Replace the per-key StampedLock (KeyedLockManager) with KeyedScheduler, a per-key event-driven serial scheduler that never blocks a pool thread on a lock across a network round-trip (the root cause of the #188 stalls). - Operations are enqueued per key and drained asynchronously via an iterative, non-reentrant loop; the only lock is a tiny per-slot mutex held for O(1) bookkeeping, never during I/O and never nested, so the design is structurally deadlock-free. - Non-blocking readers-writer scheduling with a FIFO writer barrier: fetches are shared, put/load/invalidate/lock are exclusive, and a queued writer is not starved by a stream of readers. - Aggressive coalescing via submit-time folding under the dominance order INVALIDATE > PUT > LOAD: a dominant write absorbs the redundant broadcasts of contiguous, dominated, not-yet-started predecessors while always preserving their CacheStatus registration; folding never crosses a fetch barrier. - Application locks are owned entirely by the scheduler (held token + owner client id), released on unlock or on disconnect (held, being-acquired, or still-queued), with a liveness check at grant time so a lock is never granted to an already-disconnected client. CacheStatus lock tracking and the LockID type are removed as redundant. - Every completion path is idempotent (reply callbacks may fire more than once) and always resumes the drain (try/finally), so a failed reply cannot strand coalesced callers or stall a key. Adds KeyedSchedulerTest with dedicated coverage for fairness, coalescing, token bypass, idempotency and the lock/disconnect races. Full blazingcache-core suite and the reactor tests (core/jcache/services) pass; apache-rat and spotbugs quality gates are green.
NiccoMlt
left a comment
There was a problem hiding this comment.
Left a couple of comments.
I don't know the codebase well enough to be 100% sure about them, I had some help from Claude, so they might be wrong, resolve the conversations if they are not the case
| /** | ||
| * Releases every application lock owned by a disconnected client and cancels its | ||
| * still-queued lock requests, then resumes the drain of the affected keys. This is | ||
| * the single, scheduler-driven release path used on disconnect: because the lock | ||
| * ownership ({@code heldToken} + {@code lockOwnerClientId}) is published atomically | ||
| * at dequeue, there is no window in which a held or being-acquired lock is invisible | ||
| * here, and queued lock requests that never ran are cancelled rather than later | ||
| * granted to the gone client. | ||
| */ | ||
| public void releaseLocksForClient(String clientId) { | ||
| slots.forEach((key, slot) -> { | ||
| List<SimpleCallback<String>> cancelledLocks = null; | ||
| boolean changed = false; | ||
| slot.lock.lock(); | ||
| try { | ||
| if (slot.heldToken != 0 && clientId.equals(slot.lockOwnerClientId)) { | ||
| slot.heldToken = 0; | ||
| slot.lockOwnerClientId = null; | ||
| changed = true; | ||
| } | ||
| for (Iterator<Op> it = slot.queue.iterator(); it.hasNext();) { | ||
| Op queued = it.next(); | ||
| if (queued.verb == Verb.LOCK && clientId.equals(queued.sourceClientId)) { | ||
| it.remove(); | ||
| if (cancelledLocks == null) { | ||
| cancelledLocks = new ArrayList<>(); | ||
| } | ||
| cancelledLocks.add(queued.lockOnFinish); | ||
| changed = true; | ||
| } | ||
| } | ||
| } finally { | ||
| slot.lock.unlock(); | ||
| } | ||
| if (cancelledLocks != null) { | ||
| for (SimpleCallback<String> cancelled : cancelledLocks) { | ||
| try { | ||
| cancelled.onResult(null, new Exception("client " + clientId | ||
| + " disconnected while waiting for the lock on " + key)); | ||
| } catch (Throwable t) { | ||
| LOGGER.log(Level.SEVERE, "error cancelling queued lock for key " + key, t); | ||
| } | ||
| } | ||
| } | ||
| if (changed) { | ||
| drain(slot); | ||
| } | ||
| }); | ||
| } |
There was a problem hiding this comment.
@diegosalvi How does this work if the client disconnects "at the wrong time" and then reconnects?
I mean:
- Client A on connection C1 requests
lockKey, so aLOCKoperation is enqueued in the slot of the key. Note that the reply for this request will be sent on C1, becauseCacheServerSideConnection#messageReceived>case Message.TYPE_LOCK_ENTRYcaptures it in the_channelvariable. - Connection C1 dies. The server executes
CacheServerSideConnection#channelClosed, which removes the connection from the map and then, viareleaseLocksForClient(<client A>), releases the locks held by A and cancels A's queuedLOCKoperations. - This cleanup is a one-shot scan, and I don't think it's guaranteed to catch the
LOCKfrom step 1, because message handling and disconnect handling run on different threads with no ordering between them:
- the
lockKeymessage may still be in flight inmessageReceivedwhile the cleanup runs, sosubmitLockenqueues the operation after the scan has already finished, and nobody will ever look for it again; - even if
submitLockruns during the scan,slots.forEachon aConcurrentHashMapis weakly consistent: a slot created concurrently with the iteration may simply not be visited.
- Suppose the orphaned
LOCKsurvives and A quickly reconnects with a new connection C2 (same clientId). When the operation reaches the head of the queue, the liveness check at grant time isacceptor.getActualConnectionFromClient(sourceClientId) != null, so it finds C2 and passes. - The reply carrying the token is sent on C1, which is dead.
NettyChannel.sendReplyMessageon a closed channel discards the message silently without throwing, so thegranted && !repliedguard inrunLocknever fires. - End state:
heldTokenis set, but the client never received the token, so it can'tunlockKey; and it's connected, soreleaseLocksForClientwon't run either. The key stalls until A disconnects again.
Does it make sense to you, or am I tripping?
If yes, maybe we could try by making the liveness check compare connection identity instead of mere presence, in CacheServer#lockKey, doing like:
acceptor.getActualConnectionFromClient(sourceClientId) == connAtSubmitThere was a problem hiding this comment.
Confirmed, good catch. Fixed in e78da97: the grant-time check now compares connection identity instead of mere presence, as you suggested. lockKey passes the submitting connection and the scheduler grants the lock only if acceptor.getActualConnectionFromClient(sourceClientId) == connection. If the original connection died and the client reconnected with a new one (same clientId), the check fails, the token published at dequeue is released, and the (harmless) error reply goes to the dead channel, so the key can no longer stall. releaseLocksForClient still backstops the case where the held token is already published when the disconnect cleanup scans.
There was a problem hiding this comment.
Thanks, the identity check closes the grant side. But the same asymmetry now exists on the release side: grant compares connection identity, while releaseLocksForClient still releases by clientId. Mirror sequence of the one you just fixed:
- A's connection C1 dies
- before C1's
channelClosedcleanup is processed, A reconnects as C2 - A legitimately acquires the lock on K via C2 (the identity check passes, because the request comes from C2, which is the current connection)
- C1's late cleanup runs
releaseLocksForClient("A") - it finds
lockOwnerClientId == "A"on K and releases it (it has no way to tell C2's lock from a C1 leftover) - another client acquires K while A still believes it holds it (the release is silent server-side); A only finds out later that mutual exclusion was broken when its operations with that
lockIdget rejected and its unlock fails with"no lock held"
Also, could you add the reconnect tests while you're at it?
There was a problem hiding this comment.
Done. The application lock is now owned by a specific connection, not just a client id, so grant and release are symmetric:
submitLockrecords the owning connection id; the grant-time check verifies it is still the current connection for that client id;releaseLocksForConnection(connectionId)(fromclientDisconnected) releases/cancels only that connection's locks, so a dead connection's late cleanup cannot release a lock a reconnected sibling legitimately holds.
Reconnect/robustness coverage added in KeyedSchedulerTest (releaseLocksForConnectionIgnoresOtherConnections, lockForAlreadyDisconnectedOwnerIsNotGranted, lockOwnerLivenessCheckThrowingReleasesAndDoesNotWedge). Commits dd1fca8 / 202fc1b.
| @@ -639,18 +529,12 @@ private void executeOnHandler(String name, Runnable runnable) { | |||
| } | |||
There was a problem hiding this comment.
@diegosalvi this doesn't go through the scheduler, do we actually like this?
A prefix invalidation can interleave with an in-flight putEntry on a matching key:
- Client A receives the prefix invalidation first and drops the entry, then receives the
putEntryand stores it again, resurrecting the entry - server-side, the prefix invalidation already removed A's registration for that key, so A now holds the entry while the server doesn't know it does, so future invalidations of that key are never sent to A, and it serves stale data until the entry expires
There was a problem hiding this comment.
Confirmed. It predates this PR (the old // LOCKS ??) but it is a real defect. Fixed across 31f9bb9/e78da97: invalidateByPrefix now runs in two phases.
- Phase 1: for each matching key, an exclusive scheduler op clears the holder registrations (
CacheStatus.removeKey) with no network, serialized on that key's slot with any concurrent put/load/fetch. - Phase 2, only after phase 1 completes: a single
INVALIDATE_BY_PREFIXper client (client set captured before phase 1), so the fan-out stays O(clients), not O(matching keys) - important for large prefixes. Running phase 2 after phase 1 also orders anyPUT_ENTRYfrom a concurrent put before this invalidation on the client's channel.
A put/load on a matching key landing between the two phases can leave a transient, self-healing divergence (the server briefly thinks a client still holds a key it dropped) - never the reverse 'client holds it, server does not know' direction that would serve stale data. Documented in the code with a concurrency note.
There was a problem hiding this comment.
Well done, the two-phase design looks correct to me!
Do you think it is possible to add the resurrection reproducer as a test? It's the scenario that motivated the rework.
Also, I think that it is worth documenting in the PR that the legacy prefix invalidation ignored locks entirely, prefix invalidation now waits for in-flight broadcasts and held locks on matching keys (which have no timeout).
There was a problem hiding this comment.
This one evolved after more review. I first routed prefix invalidation per-key through the scheduler, but that introduced a self-deadlock behind held (timeout-less) application locks on matching keys, plus early-ACK-via-coalescing and a fresh-client wipe. Final design in 202fc1b: keep a single INVALIDATE_BY_PREFIX broadcast per client and do not prune the server-side holder registrations at all. That makes the dangerous 'client dropped it / server forgot' direction (the one you flagged) impossible - the server never removes on prefix invalidation - leaving only the benign 'server still thinks a client holds a key it dropped', which self-heals on the next invalidate/put/fetch of the key, on expiry, or on disconnect. (So the earlier 'waits for held locks' note no longer applies: it doesn't touch the scheduler.) Added InvalidateByPrefixResurrectionTest - 300 rounds of concurrent put + prefix invalidation asserting the safe direction.
…ByPrefix - lockKey: verify at grant time that the key is still held by the SAME connection that requested the lock (identity), not merely that the client id is connected. A LOCK racing ahead of its connection's disconnect cleanup could otherwise be granted to a new connection of a reconnected client; the grant reply would be sent on the original (dead) channel and silently dropped, stalling the key. - invalidateByPrefix: route the invalidation through the scheduler per matching key instead of a single bulk prefix removal, so each is serialized on its key slot with concurrent put/load/fetch (the bulk removal could interleave with an in-flight putEntry and resurrect an entry / desync the holder registration). Removes the now-unused sendPrefixInvalidationMessage / removeKeyByPrefixForClient.
Keep the per-key scheduler serialization that makes prefix invalidation coherent with concurrent per-key operations, but restore the single INVALIDATE_BY_PREFIX message per client so the network fan-out stays O(clients) instead of O(matching keys) (important for large prefixes). - Phase 1: for each matching key, an exclusive scheduler op clears the holder registrations (CacheStatus.removeKey) with no network, serialized on that key's slot with any concurrent put/load/fetch. - Phase 2, after all phase-1 removals complete: a single prefix broadcast per client tells each one to drop its matching keys locally. The client set is captured before phase 1 (the removals would otherwise hide the very clients that held the matching keys). Running phase 2 after phase 1 also orders any PUT_ENTRY from a concurrent put before this invalidation on the client's channel.
9f9c745 to
31f9bb9
Compare
NiccoMlt
left a comment
There was a problem hiding this comment.
Thanks, both fixes look right!
Still, I left another couple of comments mostly about finishing the job.
| public void submitFetch(RawString key, String providedLockId, | ||
| Consumer<Runnable> body, Runnable onInvalidLock) { | ||
| Op op = new Op(Verb.FETCH, Mode.SHARED); | ||
| op.sharedBody = body; | ||
| submit(key, op, providedLockId, onInvalidLock); | ||
| } |
There was a problem hiding this comment.
You never set op.resultKey here, so the SEVERE error logs for fetches print "error running fetch for key null" (L515, and L678 for the lock-bypass path).
I think adding op.resultKey = key; here should be enough.
There was a problem hiding this comment.
Fixed in dd1fca8: op.resultKey = key is set in submitFetch, so the fetch (and lock-bypass) error logs no longer print for key null. Thanks!
| // If the grant reply failed to reach the client, release the just-acquired | ||
| // lock: the client does not know it holds it, so leaving heldToken set would | ||
| // stall the key forever (the client stays connected, so releaseLocksForClient | ||
| // would never fire). | ||
| if (granted && !replied) { |
There was a problem hiding this comment.
Please double-check this comment.
replied stays false only when the callback throws; when the reply fails to reach the client, i.e. when the channel is dead, it doesn't throw
(NettyChannel.sendReplyMessage silently discards on a closed socket), so this guard never fires for it. That case is actually covered by the connection-identity check at grant time plus the disconnect cleanup.
I think we should reword the comment to make this clear.
There was a problem hiding this comment.
Reworded in dd1fca8: the comment now makes clear the granted && !replied guard only covers a reply callback that throws; the reply silently dropped on an already-dead channel (NettyChannel discards without throwing) is handled instead by the connection-identity liveness check at grant time plus releaseLocksForConnection on disconnect. In 202fc1b the liveness check itself is also wrapped in try/catch so a throw there can't escape drain() and wedge the key.
| /** | ||
| * Releases every application lock owned by a disconnected client and cancels its | ||
| * still-queued lock requests, then resumes the drain of the affected keys. This is | ||
| * the single, scheduler-driven release path used on disconnect: because the lock | ||
| * ownership ({@code heldToken} + {@code lockOwnerClientId}) is published atomically | ||
| * at dequeue, there is no window in which a held or being-acquired lock is invisible | ||
| * here, and queued lock requests that never ran are cancelled rather than later | ||
| * granted to the gone client. | ||
| */ | ||
| public void releaseLocksForClient(String clientId) { | ||
| slots.forEach((key, slot) -> { | ||
| List<SimpleCallback<String>> cancelledLocks = null; | ||
| boolean changed = false; | ||
| slot.lock.lock(); | ||
| try { | ||
| if (slot.heldToken != 0 && clientId.equals(slot.lockOwnerClientId)) { | ||
| slot.heldToken = 0; | ||
| slot.lockOwnerClientId = null; | ||
| changed = true; | ||
| } | ||
| for (Iterator<Op> it = slot.queue.iterator(); it.hasNext();) { | ||
| Op queued = it.next(); | ||
| if (queued.verb == Verb.LOCK && clientId.equals(queued.sourceClientId)) { | ||
| it.remove(); | ||
| if (cancelledLocks == null) { | ||
| cancelledLocks = new ArrayList<>(); | ||
| } | ||
| cancelledLocks.add(queued.lockOnFinish); | ||
| changed = true; | ||
| } | ||
| } | ||
| } finally { | ||
| slot.lock.unlock(); | ||
| } | ||
| if (cancelledLocks != null) { | ||
| for (SimpleCallback<String> cancelled : cancelledLocks) { | ||
| try { | ||
| cancelled.onResult(null, new Exception("client " + clientId | ||
| + " disconnected while waiting for the lock on " + key)); | ||
| } catch (Throwable t) { | ||
| LOGGER.log(Level.SEVERE, "error cancelling queued lock for key " + key, t); | ||
| } | ||
| } | ||
| } | ||
| if (changed) { | ||
| drain(slot); | ||
| } | ||
| }); | ||
| } |
There was a problem hiding this comment.
Thanks, the identity check closes the grant side. But the same asymmetry now exists on the release side: grant compares connection identity, while releaseLocksForClient still releases by clientId. Mirror sequence of the one you just fixed:
- A's connection C1 dies
- before C1's
channelClosedcleanup is processed, A reconnects as C2 - A legitimately acquires the lock on K via C2 (the identity check passes, because the request comes from C2, which is the current connection)
- C1's late cleanup runs
releaseLocksForClient("A") - it finds
lockOwnerClientId == "A"on K and releases it (it has no way to tell C2's lock from a C1 leftover) - another client acquires K while A still believes it holds it (the release is silent server-side); A only finds out later that mutual exclusion was broken when its operations with that
lockIdget rejected and its unlock fails with"no lock held"
Also, could you add the reconnect tests while you're at it?
| @@ -639,18 +529,12 @@ private void executeOnHandler(String name, Runnable runnable) { | |||
| } | |||
There was a problem hiding this comment.
Well done, the two-phase design looks correct to me!
Do you think it is possible to add the resurrection reproducer as a test? It's the scenario that motivated the rework.
Also, I think that it is worth documenting in the PR that the legacy prefix invalidation ignored locks entirely, prefix invalidation now waits for in-flight broadcasts and held locks on matching keys (which have no timeout).
- KeyedScheduler.submitFetch: set op.resultKey so fetch (and lock-bypass) error logs no longer print "for key null". - lock ownership tracked by CONNECTION identity, not client id: the slot keeps the owning connection id and the disconnect release (releaseLocksForConnection) only releases/cancels that connection's locks. This closes the release-side mirror of the grant-side race: a late cleanup of a client's dead connection no longer releases a lock a new connection of the same client legitimately acquired after reconnecting. - runLock: reword the granted-but-not-replied guard comment to make clear it only covers a reply callback that throws; a reply silently dropped on a dead channel is handled by the connection-identity check plus releaseLocksForConnection. - invalidateByPrefix: document that, unlike the legacy path (which ignored locks), it now waits for held application locks (no timeout) and in-flight broadcasts on the matching keys. - tests: KeyedSchedulerTest.releaseLocksForConnectionIgnoresOtherConnections (identity release) and InvalidateByPrefixResurrectionTest (no client-holds/server-unaware divergence under concurrent put + prefix invalidation).
Mirror of the lock reconnect fix, on the near-cache holder registrations: - CacheServerEndpoint.connectionClosed now removes the client->connection mapping only if it still points to the closing connection (clientConnections.remove(id, con)), so a reconnected client's new connection is not unmapped by the late cleanup of the old, dead one. - CacheServer.clientDisconnected skips removeClientListeners when a newer connection has already taken over the same client id. Otherwise the dead connection's late cleanup would wipe the registrations the new connection just made, leaving it holding entries the server no longer tracks (stale, missed by future invalidations). When skipped, the dead connection's stale registrations are left as benign, self-healing phantoms instead of being wrongly wiped.
- invalidateByPrefix: a single INVALIDATE_BY_PREFIX broadcast per client, with NO server-side holder removal. The previous two-phase per-key scheduler removal is reverted: it could self-deadlock behind a held (timeout-less) application lock on a matching key, early-ACK a coalesced concurrent invalidate, and wipe a freshly registered client. Not pruning means only the benign "server still thinks a client holds a key it dropped" divergence can occur (self-heals on the next invalidate/put/fetch, on expiry, or on disconnect); the dangerous "client holds / server forgot" direction is impossible. Removed CacheStatus.removeKey. - runLock: wrap the lock-owner liveness check in try/catch; a throw is treated as "owner not connected" and releases the just-published token instead of leaving the key locked forever and wedging the drain with draining=true. - listener cleanup made connection-identity aware and race-free: CacheStatus tracks the owning connection per client (connectionForClient), advanced FORWARD-ONLY via merge(Math::max) since connection ids are monotonic; removeClientListeners(clientId, connectionId) removes only if that connection still owns the registrations, atomically with a concurrent registerKeyForClient under the CacheStatus lock. connectionId is threaded through putEntry/loadEntry/fetchEntry. Closes the reconnect TOCTOU where a dead connection's late cleanup wiped a reconnected connection's fresh registrations. - fetchEntry: registerKeyForClient moved inside the completion guard so a doubly-fired reply cannot create a phantom holder. - removed a dead import; added CacheStatusReconnectTest and scheduler regression tests.
Motivation
The coordinator serialized every per-key operation (fetch/get/invalidate/load/put/lock/unlock) with a per-key
StampedLock(KeyedLockManager). The write lock was held by achannelsHandlerspool thread for the entire network round-trip of the broadcast (released only in the async reply callback). Under contention on a hot key, pool threads pile up inwriteLock()/readLock()until the broadcast completes → pool exhaustion → stalls that spread to unrelated keys. This is the root cause of #188.This PR replaces that locking with an event-driven, non-blocking per-key scheduler: no pool thread is ever blocked on a lock across I/O, work on a key is serialized by an ordered queue (not a held lock), the design is structurally deadlock-free, and redundant work is coalesced.
The scheduler
New
blazingcache.server.KeyedScheduler: aConcurrentHashMap<RawString, KeySlot>where each key owns a FIFO queue drained asynchronously — submitting an op enqueues it and returns; when the running op completes (via its callback, typically fired by the network layer) the next starts.SHARED(run concurrently); put/load/invalidate/lock areEXCLUSIVE; FIFO ordering means readers arriving while a writer is queued line up behind it, so writers are never starved.INVALIDATE > PUT > LOAD: a dominant write absorbs the broadcast of contiguous, dominated, not-yet-started predecessors while always preserving theirCacheStatusregistration, and never folds across a fetch barrier. Separating the droppable broadcast from the always-kept registration is what makes the merge provably equivalent (e.g.load(B)thenput(A)keeps B registered so A's PUT still pushes it the value). Plain invalidations arriving while one is in flight attach to it (idempotent) instead of queueing another broadcast.NettyChannel.sendMessageWithAsyncReply); each op completes at most once (AtomicBoolean) and the drain is resumed in afinally, so a failed/duplicate reply cannot corrupt reader/writer accounting, strand coalesced callers, or wedge a key.KeyedLockManager,PendingInvalidationsManager,LockIDandKeyedLockManagerLockIdTestare removed; the scheduler is the single source of truth.Application locks — owned by the scheduler, tied to the connection
Locks live entirely in the scheduler (
heldToken+ owning connection id, published atomically at dequeue). Ownership is keyed on the connection, not just the client id, so both sides are reconnect-safe:releaseLocksForConnection(connectionId)(fromclientDisconnected) releases the held lock and cancels queued lock requests only for that connection — a late cleanup of a dead connection can no longer release a lock a reconnected sibling legitimately acquired.This closes the acquire-vs-disconnect race in both directions (grant and release) when a client dies and reconnects with the same id on a new connection.
invalidateByPrefix— single broadcast, no server-side pruningA single
INVALIDATE_BY_PREFIXmessage per client (fan-out stays O(clients); each client drops its matching keys locally), and the server does not prune the holder registrations. The legacy bulk removal raced an in-flightputEntryon a matching key and could leave a client holding an entry the server had forgotten (stale). Not pruning makes that dangerous direction impossible; only the benign "server still thinks a client holds a key it dropped" remains, which self-heals on the next invalidate/put/fetch of the key, on expiry, or on disconnect. (Routing prefix removal per-key through the scheduler was considered and rejected: it can self-deadlock behind a held, timeout-less application lock on a matching key, and mishandle coalescing.)A read-path self-heal (prune-on-fetch-miss: when a fetch is routed to a holder that replies it no longer has the entry, prune that holder) was also prototyped and rejected. A fetch runs as a
SHAREDop, andSHAREDis mutually exclusive only with theEXCLUSIVEwriters, not with other concurrent fetches on the same key. So the prune can race a second fetch whose fetcher happens to be the very holder being pruned: that fetch legitimately re-registers the holder (and the client caches the value) on its own reply, and the two reply callbacks commit under theCacheStatuslock in arbitrary order. If the prune commits after the re-registration it erases a valid registration, resurrecting the forbidden "client holds the key / server forgot it" state (silent stale reads, non-self-healing for immortal entries). Since the prune acts on already-stale information (the holder answered "absent" earlier, but re-acquired the key meanwhile), there is no safe single-signal fix short of per-registration versioning, so the read path deliberately does not contribute to pruning. This confirms that the server-side over-knowing left byinvalidateByPrefixcan only be healed by the write path (invalidate/put), expiry, or disconnect.Client-listener cleanup — connection-identity aware on reconnect
Holder registrations now record the owning connection (
connectionForClient, advanced forward-only viamerge(Math::max)since connection ids are monotonic).removeClientListeners(clientId, connectionId)removes a disconnected connection's registrations only if that connection still owns them, checked atomically with a concurrentregisterKeyForClientunder theCacheStatuslock. This closes the reconnect TOCTOU where a dead connection's late cleanup wiped the registrations a reconnected connection had just made.CacheServerEndpoint.connectionClosedlikewise unmaps the client only if it still points to the closing connection.Wire protocol / compatibility
No protocol change — fully backward compatible. No
Messagetype or parameter changed and the client is untouched. The only observable difference is that thelockIdreturned bylock()is now a small monotonic token instead of aStampedLockstamp, but it travels as the same opaqueStringinKeyLockand is echoed back verbatim, so it is indistinguishable on the wire. Locks live on the leader only and connections close on leadership change, so no token crosses server versions.Preserved behavior (invariants covered by existing tests)
Hot-key fetch/invalidate storm without stalls (
FetchAndInvalidateStormTest); writer not starved (WriterStarvationTest); invalidations never lost / no resurrection (FetchAndInvalidateHammerTest,ConcurrentFetchAndInvalidationTest); put-global vs load-local visibility and load/put conflicts (LoadAndPutEntryTest,LoadConcurrencyTest); per-key lock counts andLockedEntriesJMX (LostFetchMessage*,ManagementStatusMXBeanTest); explicitKeyLockincl. release on disconnect (LockBasicTest,LockLostTest); stuck/errored client doesn't deadlock the coordinator (ApparentlyStuckClientDueToServerSideErrorTest,ErrorOnFetchTest); fetch-source priority (FetchPriorityTest); prefix invalidation (InvalidateByPrefixTest,SimpleEvictMaxMemoryTest).Testing
KeyedSchedulerTest— RW fairness/writer barrier, reader batching, coalescing dominance, in-flight invalidation attach, token bypass + malformed/invalid token, per-op idempotency (shared/exclusive/bypass), lock↔disconnect (release-held, cancel-queued, not-granted-to-disconnected/gone, bypass barrier after unlock, liveness-check-throws), token-0 unlock guard, grant-reply-failure release.CacheStatusReconnectTest— connection-identity listener cleanup and forward-only ownership.InvalidateByPrefixResurrectionTest— 300 rounds of concurrent put + prefix invalidation asserting the safe direction (never "client holds it / server forgot").blazingcache-coresuite (46 test classes) green; reactor build (core + jcache + services) green; quality gates green (apache-rat,spotbugs).Known, intentionally-accepted trade-offs
invalidateByPrefixleaves the server over-knowing holder registrations for dropped keys (benign, self-healing) — the deliberate cost of the single-broadcast, deadlock-free design.releaseLocksForConnectionscans the active-key slots on disconnect (O(active keys)) instead of keeping a per-client index, to avoid reintroducing cross-structure non-atomicity.foldDominatedTailwalks the dominated tail in more than one pass (correctness-neutral micro-opt).History
The design converged over several review rounds; the commit history keeps the intermediate steps:
StampedLockwith the non-blockingKeyedScheduler(readers-writer, coalescing, app locks).try/finally); the fetch reply registration was moved inside its guard.invalidateByPrefix— first routed per-key through the scheduler, then a two-phase variant; both were reverted after review surfaced a self-deadlock behind held locks, early-ACK via coalescing, and a fresh-client wipe. The final design is the single-broadcast, no-server-pruning approach above.invalidateByPrefixsection). No code from this step ships.Each step was validated by the full test suite, the quality gates, and an adversarial code-review pass; the final state has no outstanding review findings.