nodedb_cluster/raft_loop/proposals.rs
1// SPDX-License-Identifier: BUSL-1.1
2
3//! Raft proposal API — local and leader-forwarded proposals for both
4//! the metadata group (group 0) and data groups.
5
6use crate::conf_change::ConfChange;
7use crate::error::Result;
8
9use super::loop_core::{CommitApplier, RaftLoop};
10use crate::forward::PlanExecutor;
11
12impl<A: CommitApplier, P: PlanExecutor> RaftLoop<A, P> {
13 /// Propose a command to the Raft group owning the given vShard.
14 ///
15 /// Returns `(group_id, log_index)` on success.
16 pub fn propose(&self, vshard_id: u32, data: Vec<u8>) -> Result<(u64, u64)> {
17 let mut mr = self.multi_raft.lock().unwrap_or_else(|p| p.into_inner());
18 mr.propose(vshard_id, data)
19 }
20
21 /// Durably record `applied_index` as the group's applied floor.
22 ///
23 /// `applied_index` MUST name an entry whose state-machine effects are
24 /// already durable — callers invoke this from the data-plane
25 /// apply-completion path, after the write funnel's fsync barrier has
26 /// returned for that entry. The next boot resumes Raft delivery at
27 /// `applied_index + 1`, which is what stops WAL replay and Raft replay
28 /// both applying it.
29 ///
30 /// Monotonic per group: an index at or below the current floor is a no-op.
31 pub fn save_applied_index(&self, group_id: u64, applied_index: u64) -> Result<()> {
32 let mut mr = self.multi_raft.lock().unwrap_or_else(|p| p.into_inner());
33 mr.save_applied_index(group_id, applied_index)
34 }
35
36 /// Auto-compact a group's Raft log if its configured threshold has
37 /// been reached, given the DATA-PLANE applied watermark
38 /// `applied_index`.
39 ///
40 /// `applied_index` MUST be the index the data-plane state machine has
41 /// durably applied to (NOT raft's commit index). Callers invoke this
42 /// from the data-plane apply-completion path so the
43 /// `SnapshotBuilder` can never be asked to serialize state past what
44 /// the engines have actually applied.
45 ///
46 /// Returns `Ok(true)` when a compaction was performed. No-op
47 /// (`Ok(false)`) when the group is absent, the threshold is `None`,
48 /// or the retained-entry count is below the threshold.
49 pub fn maybe_compact_group(&self, group_id: u64, applied_index: u64) -> Result<bool> {
50 let mut mr = self.multi_raft.lock().unwrap_or_else(|p| p.into_inner());
51 mr.maybe_compact_group(group_id, applied_index)
52 }
53
54 /// Propose a command directly to the metadata Raft group (group 0).
55 ///
56 /// Used by the host crate's metadata proposer and by integration
57 /// tests that exercise the replicated-catalog path without a
58 /// pgwire client. Fails with `ClusterError::GroupNotFound` if
59 /// group 0 does not exist on this node, and with
60 /// `ClusterError::Raft(NotLeader)` if this node is not the
61 /// current leader of group 0.
62 pub fn propose_to_metadata_group(&self, data: Vec<u8>) -> Result<u64> {
63 let mut mr = self.multi_raft.lock().unwrap_or_else(|p| p.into_inner());
64 mr.propose_to_group(crate::metadata_group::METADATA_GROUP_ID, data)
65 }
66
67 /// Propose to the metadata Raft group, transparently forwarding
68 /// to the current leader if this node is not it.
69 ///
70 /// Tries a local propose first. On
71 /// `ClusterError::Raft(NotLeader { leader_hint })`, looks up the
72 /// hinted leader's address in cluster topology and sends a
73 /// [`crate::rpc_codec::MetadataProposeRequest`] over QUIC. The
74 /// receiving leader applies the proposal locally and returns
75 /// the log index.
76 ///
77 /// On `NotLeader { leader_hint: None }` (election in progress,
78 /// no observed leader yet) the call returns the original
79 /// `NotLeader` error so the caller can decide whether to retry.
80 /// We deliberately do not implement a wait-and-retry loop here
81 /// because the caller (the host-side proposer) may have a
82 /// shorter deadline than any reasonable retry budget.
83 ///
84 /// The leader-side path through this function is identical to
85 /// the bare `propose_to_metadata_group` — the only extra cost is
86 /// an `is_leader_locally` check before the local propose.
87 pub async fn propose_to_metadata_group_via_leader(&self, data: Vec<u8>) -> Result<u64> {
88 // First, try a local propose.
89 match self.propose_to_metadata_group(data.clone()) {
90 Ok(idx) => Ok(idx),
91 Err(crate::error::ClusterError::Raft(nodedb_raft::RaftError::NotLeader {
92 leader_hint,
93 })) => {
94 let Some(leader_id) = leader_hint else {
95 return Err(crate::error::ClusterError::Raft(
96 nodedb_raft::RaftError::NotLeader { leader_hint: None },
97 ));
98 };
99 if leader_id == self.node_id {
100 // Should not happen — local propose said we
101 // weren't leader but the hint points at us. Fall
102 // through to the original error so the caller
103 // sees the contradiction.
104 return Err(crate::error::ClusterError::Raft(
105 nodedb_raft::RaftError::NotLeader {
106 leader_hint: Some(leader_id),
107 },
108 ));
109 }
110 // Otherwise forward to the hinted leader.
111 self.forward_metadata_propose(leader_id, data).await
112 }
113 Err(other) => Err(other),
114 }
115 }
116
117 /// Send a `MetadataProposeRequest` to `leader_id`. Looks up the
118 /// leader's listen address via the local topology snapshot and
119 /// dispatches through the existing peer transport.
120 async fn forward_metadata_propose(&self, leader_id: u64, data: Vec<u8>) -> Result<u64> {
121 // Resolve and register the leader's address with the
122 // transport so `send_rpc` has a destination. Topology is
123 // updated by the membership / health subsystem; if the
124 // leader isn't in our local topology yet we fail loudly so
125 // the caller can fall back to its own retry policy rather
126 // than silently dropping the proposal.
127 {
128 let topo = self.topology.read().unwrap_or_else(|p| p.into_inner());
129 let Some(node) = topo.get_node(leader_id) else {
130 return Err(crate::error::ClusterError::Transport {
131 detail: format!(
132 "metadata propose forward: leader {leader_id} not in local topology"
133 ),
134 });
135 };
136 let Some(addr) = node.socket_addr() else {
137 return Err(crate::error::ClusterError::Transport {
138 detail: format!(
139 "metadata propose forward: leader {leader_id} has unparseable addr {:?}",
140 node.addr
141 ),
142 });
143 };
144 // Idempotent: register_peer overwrites any prior mapping.
145 self.transport.register_peer(leader_id, addr);
146 }
147
148 let req = crate::rpc_codec::RaftRpc::MetadataProposeRequest(
149 crate::rpc_codec::MetadataProposeRequest { bytes: data },
150 );
151 let resp = self.transport.send_rpc(leader_id, req).await?;
152 match resp {
153 crate::rpc_codec::RaftRpc::MetadataProposeResponse(r) => {
154 if r.success {
155 Ok(r.log_index)
156 } else if let Some(hint) = r.leader_hint {
157 // The receiving node was also not the leader
158 // (rare: leader changed between our local check
159 // and the forwarded RPC). Surface as NotLeader
160 // so the caller's normal retry path runs.
161 Err(crate::error::ClusterError::Raft(
162 nodedb_raft::RaftError::NotLeader {
163 leader_hint: Some(hint),
164 },
165 ))
166 } else {
167 Err(crate::error::ClusterError::Transport {
168 detail: format!("metadata propose forward failed: {}", r.error_message),
169 })
170 }
171 }
172 other => Err(crate::error::ClusterError::Transport {
173 detail: format!("metadata propose forward: unexpected response variant {other:?}"),
174 }),
175 }
176 }
177
178 /// Propose a command to the data Raft group owning the given vShard,
179 /// transparently forwarding to the group leader if this node is not it.
180 ///
181 /// Tries a local propose first. On `NotLeader { leader_hint: Some(id) }`,
182 /// looks up the hinted leader's address in the cluster topology and sends
183 /// a `DataProposeRequest` over QUIC. The receiving leader applies the
184 /// proposal locally and returns `(group_id, log_index)`.
185 ///
186 /// On `NotLeader { leader_hint: None }` (election in progress) the call
187 /// returns the original `NotLeader` error so the caller can retry.
188 pub async fn propose_via_data_leader(
189 &self,
190 vshard_id: u32,
191 data: Vec<u8>,
192 ) -> Result<(u64, u64)> {
193 // First, try a local propose.
194 match self.propose(vshard_id, data.clone()) {
195 Ok(pair) => Ok(pair),
196 Err(crate::error::ClusterError::Raft(nodedb_raft::RaftError::NotLeader {
197 leader_hint,
198 })) => {
199 let Some(leader_id) = leader_hint else {
200 return Err(crate::error::ClusterError::Raft(
201 nodedb_raft::RaftError::NotLeader { leader_hint: None },
202 ));
203 };
204 if leader_id == self.node_id {
205 return Err(crate::error::ClusterError::Raft(
206 nodedb_raft::RaftError::NotLeader {
207 leader_hint: Some(leader_id),
208 },
209 ));
210 }
211 // Otherwise forward to the hinted leader.
212 self.forward_data_propose(leader_id, vshard_id, data).await
213 }
214 Err(other) => Err(other),
215 }
216 }
217
218 /// Send a `DataProposeRequest` to `leader_id`.
219 async fn forward_data_propose(
220 &self,
221 leader_id: u64,
222 vshard_id: u32,
223 data: Vec<u8>,
224 ) -> Result<(u64, u64)> {
225 {
226 let topo = self.topology.read().unwrap_or_else(|p| p.into_inner());
227 let Some(node) = topo.get_node(leader_id) else {
228 return Err(crate::error::ClusterError::Transport {
229 detail: format!(
230 "data propose forward: leader {leader_id} not in local topology"
231 ),
232 });
233 };
234 let Some(addr) = node.socket_addr() else {
235 return Err(crate::error::ClusterError::Transport {
236 detail: format!(
237 "data propose forward: leader {leader_id} has unparseable addr {:?}",
238 node.addr
239 ),
240 });
241 };
242 self.transport.register_peer(leader_id, addr);
243 }
244
245 let req =
246 crate::rpc_codec::RaftRpc::DataProposeRequest(crate::rpc_codec::DataProposeRequest {
247 vshard_id,
248 bytes: data,
249 });
250 let resp = self.transport.send_rpc(leader_id, req).await?;
251 match resp {
252 crate::rpc_codec::RaftRpc::DataProposeResponse(r) => {
253 if r.success {
254 Ok((r.group_id, r.log_index))
255 } else if let Some(hint) = r.leader_hint {
256 Err(crate::error::ClusterError::Raft(
257 nodedb_raft::RaftError::NotLeader {
258 leader_hint: Some(hint),
259 },
260 ))
261 } else {
262 Err(crate::error::ClusterError::Transport {
263 detail: format!("data propose forward failed: {}", r.error_message),
264 })
265 }
266 }
267 other => Err(crate::error::ClusterError::Transport {
268 detail: format!("data propose forward: unexpected response variant {other:?}"),
269 }),
270 }
271 }
272
273 /// Propose a configuration change to a Raft group.
274 ///
275 /// Returns `(group_id, log_index)` on success.
276 pub fn propose_conf_change(&self, group_id: u64, change: &ConfChange) -> Result<(u64, u64)> {
277 let mut mr = self.multi_raft.lock().unwrap_or_else(|p| p.into_inner());
278 mr.propose_conf_change(group_id, change)
279 }
280}