Add client blocking mechanism for keys in use - #3341
Conversation
A client blocking system (blockedInUse) that prevents concurrent access to keys actively being modified by internal operations (e.g., bgIteration). The mechanism blocks clients attempting to access in-use keys and automatically unblocks them when keys become available. Signed-off-by: harrylin98 <harrylin980107@gmail.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## forkless #3341 +/- ##
============================================
+ Coverage 74.56% 74.61% +0.05%
============================================
Files 130 131 +1
Lines 72730 72902 +172
============================================
+ Hits 54228 54394 +166
- Misses 18502 18508 +6
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR introduces a new internal “blocked-in-use” client blocking mechanism to prevent clients from accessing keys that are currently being operated on by internal/background work, and integrates it into the main server loop and unit tests.
Changes:
- Add
blocked_inuse.{c,h}implementing client↔keys and key↔clients mappings, plus block/unblock/unlink APIs. - Integrate the mechanism into client lifecycle paths (timeouts, unlinking, unblocked-client processing, shutdown init/release, and client info flags).
- Add GoogleTest coverage and wrapper hooks for mocking relevant server functions.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/blocked_inuse.c |
Implements the core block/unblock/unlink logic and internal hashtable mappings. |
src/blocked_inuse.h |
Declares the blockInuse public API and documents intended workflow. |
src/server.h |
Adds a new client flag bit (blockInuse_blocked). |
src/server.c |
Initializes/releases blockInuse; adds TCP close detection for blocked clients; asserts blocked-state invariants. |
src/networking.c |
Unlinks blockInuse-blocked clients during unlinkClient; prints new client flag X; adjusts pending-command processing behavior. |
src/blocked.c |
Updates processUnblockedClients() to support restoring read handlers for blockInuse-unblocked clients and adds pause/close_asap handling. |
src/timeout.c |
Exempts blockInuse-blocked clients from max-idle timeout enforcement. |
src/unit/test_blockedInuse.cpp |
Adds unit tests covering single/multi-key and multi-client scenarios, plus death tests. |
src/unit/wrappers.h |
Adds wrapper declarations needed to mock lookupKeyRead, processPendingCommandAndInputBuffer, and beforeNextClient in tests. |
src/Makefile |
Adds blocked_inuse.o to the server object list. |
cmake/Modules/SourceFiles.cmake |
Adds src/blocked_inuse.c to the CMake server sources list. |
You can also share your feedback on Copilot code review. Take the survey.
65fdf78 to
af246a5
Compare
Signed-off-by: harrylin98 <harrylin980107@gmail.com>
af246a5 to
85b57ce
Compare
|
|
||
| /* Reinstall read handler if it was removed (e.g. by blockInuse) */ | ||
| if (c->conn && !connHasReadHandler(c->conn)) { | ||
| // If it fails because epoll_ctl failed then freeClient. |
There was a problem hiding this comment.
This comment is wrong, since we support multiple back ends. This comment is also not very helpful, as it's just describing what is happening. There are a lot of "what" comments in this CR, which could probably get removed.
There was a problem hiding this comment.
Is it resolved? I still see the comment
|
. |
…r) (valkey-io#3349) This is pre-submission into the `forkless-pre-bgiterator` branch for client blocking mechanism. The actual PR review for this is here: valkey-io#3341 Submitting to this branch to enable PR review for bgIterator. Signed-off-by: harrylin98 <harrylin980107@gmail.com>
ranshid
left a comment
There was a problem hiding this comment.
Overall I think this PR is missing capturing much of the motivation.
To me, it seems we wanted something like a combination of postpone+keys blocking type which also remove the read handler in order to avoid reading data and consuming more memory.
The entire blocked_inuse is what needs to be motivated IMO for example:
- why do we need a complete separated module and not use/extend the blocked.c to handle this block type?
- why do we need a new way to map keys to blocked clients and not use the blockForKeys?
|
|
||
| while (listLength(server.unblocked_clients)) { | ||
| // If one of the unblocked clients executed pause command, then we stop processing further. | ||
| if (isPausedActionsWithUpdate(PAUSE_ACTIONS_CLIENT_ALL_SET)) return; |
There was a problem hiding this comment.
Can you better explain the motivation around this? why are we only targeting PAUSE_ACTIONS_CLIENT_ALL_SET and not PAUSE_ACTION_CLIENT_WRITE for example?
There was a problem hiding this comment.
The motivation is to early-terminate processUnblockedClients when a full pause is active. Under PAUSE_ACTIONS_CLIENT_ALL_SET, no client commands should be "processed" at all, so there's no point iterating further. The early return avoids that churn and leaves all remaining clients in unblocked_clients to be picked up in a future beforeSleep() once the pause lifts.
Under PAUSE_ACTIONS_CLIENT_WRITE_SET, reads are still allowed to execute, so we let the loop continue — processCommand will postpone-block write commands and let read commands run normally.
Rephrase the comments in the code.
There was a problem hiding this comment.
I see. so it is strange this is placed inside the while loop. lets just take it outside.
There was a problem hiding this comment.
Moving it outside is not quiet right...
Internal pause state could be updated by any of the unblocked client's processCommand, so it needs to be checked before each processCommand case.
I will clarify the comments for this.
There was a problem hiding this comment.
Under PAUSE_ACTIONS_CLIENT_ALL_SET, no client commands should be "processed" at all, so there's no point iterating further.
I am not fully sure the behavior is identical to the previous behavior.
So consider this sequence of events:
- Client blocked (e.g. by module)
- Server paused with actions == ALL
- Client unblocked
Before, we would go into processCommand, and in there we have various different checks execute:
Lines 4223 to 4550 in 9586093
After, we would not go into processCommand, so all the linked lines will not execute.
Maybe you can help me understand why we are adding this change? If the reason is just optimization, I think we should skip this change. If the change is needed, we should double check that none of the functionality changes would be problematic
There was a problem hiding this comment.
Yes it is an optimization for early return, and yes this will be processed later in processCommand.
I will remove it to keep the logic same.
3c48c9c to
eb3a9b0
Compare
a6a6fc5 to
2f83503
Compare
| * blocked keys. Such clients are added to the server.unblocked_clients list and | ||
| * resumed later during processUnblockedClients() in blocked.c. | ||
| */ | ||
| void blockInuse_unblockClientsOnKey(robj *key); |
There was a problem hiding this comment.
not specific to this code, but we should think through whether there are issues between this and #3381 -- does the code here need to handle blocked on keys?
Signed-off-by: harrylin98 <harrylin980107@gmail.com>
f90455f to
b7676dd
Compare
Thanks for asking, I will also update in the PR description. Here are for the reply:
Current blocking is built around client-initiated command blocking, which is a coherent system where unblocking is driven by keyspace events.
|
Signed-off-by: harrylin98 <harrylin980107@gmail.com>
Is this true? Blocking is also used on server shutdown, on failover, on slot migration, on CLIENT PAUSE - there are many other cases where we use blocking for server-driven blocking causes and where unblocking is automatic (either by timeout, or by a server operation completion). The only difference I can find is here we want to block by key, whereas the other server-driven blocking causes are blocking all operations at the server level. But we also have issues like #3406 where we would be doing a server-driven per-slot pause. Building a one-off parallel subsystem just for "in-use" keys creates technical debt and fragmentation. IMO we should continue to build out the existing blocking subsystem to handle these use cases rather than having competing subsystems. |
You're right that blocked.c handles server-driven blocking too, and as you noted, the key difference is per-key granularity. The existing per-key infrastructure I would still stand by the parallel blockedInuse system. Extending |
|
|
||
| while (listLength(server.unblocked_clients)) { | ||
| // If one of the unblocked clients executed pause command, then we stop processing further. | ||
| if (isPausedActionsWithUpdate(PAUSE_ACTIONS_CLIENT_ALL_SET)) return; |
There was a problem hiding this comment.
Under PAUSE_ACTIONS_CLIENT_ALL_SET, no client commands should be "processed" at all, so there's no point iterating further.
I am not fully sure the behavior is identical to the previous behavior.
So consider this sequence of events:
- Client blocked (e.g. by module)
- Server paused with actions == ALL
- Client unblocked
Before, we would go into processCommand, and in there we have various different checks execute:
Lines 4223 to 4550 in 9586093
After, we would not go into processCommand, so all the linked lines will not execute.
Maybe you can help me understand why we are adding this change? If the reason is just optimization, I think we should skip this change. If the change is needed, we should double check that none of the functionality changes would be problematic
|
|
||
| /* Reinstall read handler if it was removed (e.g. by blockInuse) */ | ||
| if (c->conn && !connHasReadHandler(c->conn)) { | ||
| // If it fails because epoll_ctl failed then freeClient. |
There was a problem hiding this comment.
Is it resolved? I still see the comment
| // Disable client’s Read Handler to prevent reading commands while blocked | ||
| if (c->conn) { | ||
| connSetReadHandler(c->conn, NULL); | ||
| } |
There was a problem hiding this comment.
I get the argument to remove the read handler to prevent unbounded buffering. But why should we only remove the read handler for blocking for in use keys? Wouldn't this memory accumulation also be true for server blocks, like failover and slot migration?
If we don't need to have two different blocking behaviors (with read handler and without) then the system will be a lot less complex.
There was a problem hiding this comment.
BLOCKED_POSTPONE blocks happen in the middle of the command exection — the client already sent a command, and the server defers its execution briefly. I think these are short-lived by design (failover completes, pause ends, migration finishes), so the read handler staying active doesn't cause meaningful memory accumulation.
There was a problem hiding this comment.
But there are other types of blocks too that may benefit from this same behavior, right? What is special about blocking for in use keys when compared to say blocking for failover or blocking for slot migration.
There was a problem hiding this comment.
I think these are short-lived by design (failover completes, pause ends, migration finishes), so the read handler staying active doesn't cause meaningful memory accumulation.
How long is something expected to be blocked for background processing by bgiterartor? Slot migration and failover can have blocks up to O(seconds) before timeout.
There was a problem hiding this comment.
There is no accurate estimation for how long for a key to be blocked, caller needs to decided when to unblock. We can rework slot migration blocking to also remove the read handler in the future if needed.
a5a650b to
b878b44
Compare
Signed-off-by: harrylin98 <harrylin980107@gmail.com>
b878b44 to
7c0bdb5
Compare
| if (client->flag.pubsub) *p++ = 'P'; | ||
| if (client->flag.multi) *p++ = 'x'; | ||
| if (client->flag.blocked) *p++ = 'b'; | ||
| if (blockInUse_isClientBlocked(client)) *p++ = 'X'; |
There was a problem hiding this comment.
Why is this a separate flag? Can we just the same 'b'
…r) (valkey-io#3349) This is pre-submission into the `forkless-pre-bgiterator` branch for client blocking mechanism. The actual PR review for this is here: valkey-io#3341 Submitting to this branch to enable PR review for bgIterator. Signed-off-by: harrylin98 <harrylin980107@gmail.com>
Summary
This PR adds
blockInuse, a server-initiated client blocking mechanism that prevents concurrent access to keys held exclusively by internal operations. When a client command targets a key that is currently in use, the client is blocked and its read handler is removed to prevent unbounded input buffering. Once the internal operation releases the key, the client is automatically resumed and its pending command is re-executed.This is a building block for upcoming bgIteration that require exclusive key access without stalling the event loop.
Design Decisions
Isolated module
Current blocking in blocked.c is built around client-initiated command blocking, which is a coherent system where unblocking is driven by keyspace events.
blockInuseis server-initiated blocking where unblocking is driven by explicit internal operation completion. Isolating blockInuse in its own module keeps the two unblock triggers separate, avoids embedding blockInuse state into client->bstate and serverDb structures that it has no logicalrelationship with, and gives it a clearly defined API and invariants.
Removing READ handler
When a client is blocked_inuse blocked, its read handler is removed so the event loop stops monitoring read events for that connection, preventing new commands from being buffered into
c->querybufwhile the client is waiting. Clients would otherwise continue buffering incoming commands during the entire blocking period, leading to unbounded memory growth across all blocked clients. The read handler is restored inprocessUnblockedClients()when the client is unblocked.The tradeoff is that removing the read handler also makes the event loop blind to TCP disconnects on that client connection. To avoid leaking zombie file descriptors,
clientsCronTcpIsClosing()is added to detect and free connections that were closed by the remote side while the read handler was removed.Observability
Clients blocked by blockInuse are visible as flag
XinCLIENT LISToutput.Testing
Unit tests (src/unit/test_blockedInuse.cpp) cover each public API: