Skip to main content

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}