[worker] Funnel all command proposals through LeaderState::run - #5055
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fbb5177d4d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let decision = rpc::RpcHandler::handle(context, body).await; | ||
|
|
||
| match decision { | ||
| rpc::Decision::Propose(proposal) => permit.buffer_rpc_proposal(proposal, response_tx), |
There was a problem hiding this comment.
Preserve pause fencing when buffering RPC proposals
When the selected network branch handles a pause RPC for a VQueue-owned invocation, this now only enqueues the proposal. Before that queued NetworkService event is drained, LeaderState::handle_events can process a ready LeaderEvent::Invoker first (crates/worker/src/partition/leadership/leader_state.rs:451-469), and the fencing token is not removed until propose_pause_and_fence appends the pause (crates/worker/src/partition/leadership/leader_state.rs:677-687). In that race, an invoker effect that arrives after the pause RPC was selected by the PP can still be self-proposed ahead of the pause; previously the RPC branch called propose_pause_and_fence directly before returning to the select loop, so this extra buffering window regresses the pause/fencing ordering.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
This seems like a real problem, will need to read the code around the fencing a bit to figure out a way around it.
There was a problem hiding this comment.
I think this is not a problem as we want to fence invoker effects once the pause command has been written to Bifrost. That way we prevent that effects from a previous attempt mess up with a resumed invocation attempt. With the newly added channel we simply delay writing the pause command to Bifrost a little bit.
tillrohrmann
left a comment
There was a problem hiding this comment.
Thanks for creating this PR @MohamedBassem. The network rpc bounded channel of size 1 shouldn't have a big impact on the overall rpc throughput rate, right? The only rpc for which this could be relevant is the ingestion rpc as the others are expected to be of low frequency. I would assume that it will increase the tail latencies of the RPC calls a little bit as the select statement needs to select the leadership::run path on a following loop iteration.
What's the plan for when we have the leader events scheduler? Would the rpc channel keep a limit of 1? Would the other channels get a different capacity? If yes, would this affect the fairness of the scheduler?
We can also answer these questions once we get to the scheduler. So +1 for merging this PR :-)
| ) -> Result< | ||
| tokio::sync::mpsc::OwnedPermit<NetworkServiceEvent>, | ||
| tokio::sync::mpsc::error::TrySendError<tokio::sync::mpsc::Sender<NetworkServiceEvent>>, | ||
| > { |
There was a problem hiding this comment.
Looking at the call sites, we could simplify this method by returning Option<OwnedPermit<NetworkServiceEvent>> since the caller will probably not do anything with the cloned network_events_tx anyway.
| let network_processing_permit = | ||
| self.leadership_state.try_reserve_rpc_processing_permit(); |
There was a problem hiding this comment.
There is no problem that we are only trying to reserve the permit at the beginning of every loop iteration because draining the network rpc tx and thereby creating new capacity/permits will always complete one of the select arms, right?
There was a problem hiding this comment.
If I understand your question correctly, we only need at most one permit per loop because the permit is consumed only from a single select branch (the network branch), and that branch produces at most one message per branch.
There was a problem hiding this comment.
I was thinking whether we need to select over the availability of the rpc processing permit to trigger a new loop iteration in case we didn't obtain a permit at the beginning. But I think this is not needed as we will finish the loop once we consume from the network rpc channel (and thereby creating new permits).
There was a problem hiding this comment.
Ah I see what you mean. The permit actually can only be available once the LeaderState::run branch completes as it's the one that consumes the prev value. So we're guaranteed a loop iteration once the permit is available.
| let decision = rpc::RpcHandler::handle(context, body).await; | ||
|
|
||
| match decision { | ||
| rpc::Decision::Propose(proposal) => permit.buffer_rpc_proposal(proposal, response_tx), |
There was a problem hiding this comment.
I think this is not a problem as we want to fence invoker effects once the pause command has been written to Bifrost. That way we prevent that effects from a previous attempt mess up with a resumed invocation attempt. With the newly added channel we simply delay writing the pause command to Bifrost a little bit.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fbb5177d4d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| mod state_machine; | ||
| pub mod types; | ||
|
|
||
| use self::leadership::RpcProcessingPermit; |
There was a problem hiding this comment.
Move the local import into the required stanza
Move RpcProcessingPermit to the local-crate import stanza after the restate_* imports; placing this self import before the std stanza violates the repository's mandatory Rust import ordering.
AGENTS.md reference: AGENTS.md:L38-L42
Useful? React with 👍 / 👎.
| let config = self.node_ctx.config.live_load(); | ||
| let max_batching_size = config.worker.max_command_batch_size(); | ||
| let bytes_limit = config.worker.max_command_batch_bytes.as_usize(); | ||
| let network_processing_permit = |
There was a problem hiding this comment.
Are we dropping the network_processing_permit on every loop iteration? It probably falls into the category of micro/pre-mature optimizations to cache the permit as the channel is not contended to avoid it to safe a few atomic operations.
There was a problem hiding this comment.
Yes, we can cache it. The only caveat is that we'll need to remember to drop it when leader_state changes (e.g. leader to follower transitions), otherwise messages will get lost. Let me see if there's an easy way I can prevent this mismatch.
There was a problem hiding this comment.
I think for simplicity, I'll leave it as is, I think it'll become a footgun as its lifecycle is not associated with the underlying leader state and can easily diverge.
| // Close the network events channel and drain it to respond to all pending requests. | ||
| // The drain must not await: the partition processor's main loop reserves a permit | ||
| // before its select! and holds it across the arm that calls stop(), and recv() | ||
| // doesn't return `None` while a permit is outstanding — awaiting here would | ||
| // deadlock. Since that loop is the only sender and processes one arm at a time, | ||
| // the outstanding permit is guaranteed unused and every sent event is already | ||
| // buffered, so a non-blocking drain loses nothing. | ||
| self.network_events_stream.close(); | ||
| while let Some(Some(event)) = self.network_events_stream.next().now_or_never() { | ||
| match event { | ||
| NetworkServiceEvent::RpcProposal { reciprocal, .. } => reciprocal.send(Err( | ||
| PartitionProcessorRpcError::LostLeadership(self.partition_id), | ||
| )), | ||
| NetworkServiceEvent::IngestRecords { reciprocal, .. } => reciprocal.send( | ||
| ResponseStatus::NotLeader { | ||
| of: self.partition_id, | ||
| } | ||
| .into(), | ||
| ), | ||
| } | ||
| } |
There was a problem hiding this comment.
Given that we don't do message passing between different tasks, could we replace the network_events_stream channel with a simpler VecDeque which is being passed as part of the Processor trait? It probably falls under the category of micro optimizations and might not work if in a later PR we are relying on the fact that we have the network_events_stream channel.
There was a problem hiding this comment.
Yes we can. I actually started with an Option<..> for a single slot message, then I thought we can do VecDeque to make the size configurable, but then I didn't like the API contract that the capacity check happens in the select guard and then inside it, we blindly write to the vecdeque hoping that we're within capacity. Instead of building a permit system on top, I decided to just reach for a channel for simplicity. This was all before writing the scheduler, though. The scheduler as you'll see in a later PR, is poll based, so the VecDeque wrapper will need to get a Notify as well to wake the scheduler if a write happens to this VecDeque (or at least persist the waker and call it on writes). So it slowly converges into a channel. I honestly don't have a sense of how much perf gains this can make, I can try to test it, and see if it makes a difference.
There was a problem hiding this comment.
No need to do that if we later need the channel API in the scheduler.
This PR changes the handling of the PP RPCs and ingestion requests from happening inside the main PP loop and into the leader state run loop. This is done by introducing a new channel that gets consumed in the large stream_select of `LeaderState` run. With that all main callsites to `SelfProposer` are encapsulated inside `LeaderState` and in a lter PR will be dispatched by a new Self proposer scheduler (*). A couple of caveats to be aware of: 1. The new channel is of size one to maintain the semantics of keeping only one inflight RPC not enqueued in self proposer. I don't forsee any problems in increasing it though if needed. 2. The network service arm is now guarded by having a permit into this new channel. This means that although we could service stuff like `DedupQquery` (which doesn't propose commands), it'll currenly be blocked until `SelfProposer` is unblocked. This is not very different from now though. 3. `ActionEffects` is no longer effects, and I renamed it to `LeaderEvents` now that it carries also network service actions. Now the new `NetworkServiceEvent` is huge (~0.5KB) and it made the entire `LeaderEvent` wider. Luckily, we only keep 10 of those in memory at a time. In a later PR, I'm getting rid of this enum altogether. (*) Two other callsites to self proposer remain: the one callsite when candidate to send the `AnnounceLeader` command, and after becoming the leader to do any `VersionBarrier` command. Those two are rare and will be excluded from the SelfProposer backpressure.
This PR changes the handling of the PP RPCs and ingestion requests from happening inside the main PP loop and into the leader state run loop. This is done by introducing a new channel that gets consumed in the large stream_select of
LeaderStaterun. With that all main callsites toSelfProposerare encapsulated insideLeaderStateand in a lter PR will be dispatched by a new Self proposer scheduler (*).A couple of caveats to be aware of:
DedupQquery(which doesn't propose commands), it'll currenly be blocked untilSelfProposeris unblocked. This is not very different from now though.ActionEffectsis no longer effects, and I renamed it toLeaderEventsnow that it carries also network service actions. Now the newNetworkServiceEventis huge (~0.5KB) and it made the entireLeaderEventwider. Luckily, we only keep 10 of those in memory at a time. In a later PR, I'm getting rid of this enum altogether.(*) Two other callsites to self proposer remain: the one callsite when candidate to send the
AnnounceLeadercommand, and after becoming the leader to do anyVersionBarriercommand. Those two are rare and will be excluded from the SelfProposer backpressure.Stack created with Sapling. Best reviewed with ReviewStack.