1use super::identity::NodeIdentity;
9use super::ownership::{
10 CatalogVersion, CollectionId, OwnershipEpoch, RangeId, ShardOwnershipCatalog,
11};
12use super::ownership_transition::{
13 run_transition, CatchUpEvidence, CommitWatermark, TransitionError, TransitionKind,
14 TransitionOutcome, TransitionRequest,
15};
16
17#[derive(Debug)]
19pub struct HotMirrorInputs<'a> {
20 catalog: &'a ShardOwnershipCatalog,
21 collection: CollectionId,
22 range_id: RangeId,
23 watermark: CommitWatermark,
24 catch_up: Vec<CatchUpEvidence>,
25}
26
27impl<'a> HotMirrorInputs<'a> {
28 pub fn new(
29 catalog: &'a ShardOwnershipCatalog,
30 collection: CollectionId,
31 range_id: RangeId,
32 watermark: CommitWatermark,
33 ) -> Self {
34 Self {
35 catalog,
36 collection,
37 range_id,
38 watermark,
39 catch_up: Vec::new(),
40 }
41 }
42
43 pub fn with_catch_up(mut self, evidence: CatchUpEvidence) -> Self {
44 self.catch_up.push(evidence);
45 self
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum WatermarkOutcome {
52 Covered,
53 Behind { applied_term: u64, applied_lsn: u64 },
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct HotMirrorFailoverCandidate {
59 pub candidate: NodeIdentity,
60 pub watermark_outcome: WatermarkOutcome,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct HotMirrorRangeEvidence {
66 pub collection: CollectionId,
67 pub range_id: RangeId,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct HotMirrorEpochEvidence {
73 pub previous: OwnershipEpoch,
74 pub new: OwnershipEpoch,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct HotMirrorFailoverEvidence {
80 pub workflow: &'static str,
81 pub range: HotMirrorRangeEvidence,
82 pub previous_owner: NodeIdentity,
83 pub promoted_owner: NodeIdentity,
84 pub epoch: HotMirrorEpochEvidence,
85 pub watermark: CommitWatermark,
86 pub watermark_outcome: WatermarkOutcome,
87}
88
89impl HotMirrorFailoverEvidence {
90 fn from_transition(outcome: TransitionOutcome) -> Self {
91 Self {
92 workflow: "hot-mirror-zero-rpo",
93 range: HotMirrorRangeEvidence {
94 collection: outcome.collection,
95 range_id: outcome.range_id,
96 },
97 previous_owner: outcome.previous_owner,
98 promoted_owner: outcome.new_owner,
99 epoch: HotMirrorEpochEvidence {
100 previous: outcome.previous_epoch,
101 new: outcome.new_epoch,
102 },
103 watermark: outcome.watermark,
104 watermark_outcome: WatermarkOutcome::Covered,
105 }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct HotMirrorFailoverPlan {
112 collection: CollectionId,
113 range_id: RangeId,
114 previous_owner: NodeIdentity,
115 expected_epoch: OwnershipEpoch,
116 expected_version: CatalogVersion,
117 watermark: CommitWatermark,
118 current_replicas: Vec<NodeIdentity>,
119 eligible_candidates: Vec<NodeIdentity>,
120 eligible_evidence: Vec<CatchUpEvidence>,
121 ineligible_candidates: Vec<HotMirrorFailoverCandidate>,
122}
123
124impl HotMirrorFailoverPlan {
125 pub fn previous_owner(&self) -> &NodeIdentity {
126 &self.previous_owner
127 }
128
129 pub fn eligible_candidates(&self) -> &[NodeIdentity] {
130 &self.eligible_candidates
131 }
132
133 pub fn ineligible_candidates(&self) -> &[HotMirrorFailoverCandidate] {
134 &self.ineligible_candidates
135 }
136
137 fn evidence_for(&self, candidate: &NodeIdentity) -> Option<CatchUpEvidence> {
138 self.eligible_evidence
139 .iter()
140 .find(|evidence| evidence.candidate == *candidate)
141 .cloned()
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum HotMirrorFailoverError {
148 UnknownRange {
149 collection: CollectionId,
150 range_id: RangeId,
151 },
152 CandidateNotEligible {
153 collection: CollectionId,
154 range_id: RangeId,
155 candidate: NodeIdentity,
156 },
157 Transition(TransitionError),
158}
159
160impl std::fmt::Display for HotMirrorFailoverError {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 match self {
163 Self::UnknownRange {
164 collection,
165 range_id,
166 } => write!(
167 f,
168 "no range {collection}/{range_id} for hot mirror failover"
169 ),
170 Self::CandidateNotEligible {
171 collection,
172 range_id,
173 candidate,
174 } => write!(
175 f,
176 "candidate {candidate} is not an eligible hot mirror for {collection}/{range_id}"
177 ),
178 Self::Transition(err) => write!(f, "{err}"),
179 }
180 }
181}
182
183impl std::error::Error for HotMirrorFailoverError {}
184
185pub fn plan_hot_mirror_failover(
188 inputs: &HotMirrorInputs<'_>,
189) -> Result<HotMirrorFailoverPlan, HotMirrorFailoverError> {
190 let range = inputs
191 .catalog
192 .range(&inputs.collection, inputs.range_id)
193 .ok_or_else(|| HotMirrorFailoverError::UnknownRange {
194 collection: inputs.collection.clone(),
195 range_id: inputs.range_id,
196 })?;
197
198 let mut eligible_candidates = Vec::new();
199 let mut eligible_evidence = Vec::new();
200 let mut ineligible_candidates = Vec::new();
201
202 for replica in range.replicas() {
203 let Some(evidence) = inputs
204 .catch_up
205 .iter()
206 .find(|evidence| evidence.candidate == *replica)
207 else {
208 ineligible_candidates.push(HotMirrorFailoverCandidate {
209 candidate: replica.clone(),
210 watermark_outcome: WatermarkOutcome::Behind {
211 applied_term: 0,
212 applied_lsn: 0,
213 },
214 });
215 continue;
216 };
217
218 if evidence.covers(inputs.watermark) {
219 eligible_candidates.push(replica.clone());
220 eligible_evidence.push(evidence.clone());
221 } else {
222 ineligible_candidates.push(HotMirrorFailoverCandidate {
223 candidate: replica.clone(),
224 watermark_outcome: WatermarkOutcome::Behind {
225 applied_term: evidence.applied_term,
226 applied_lsn: evidence.applied_lsn,
227 },
228 });
229 }
230 }
231
232 Ok(HotMirrorFailoverPlan {
233 collection: inputs.collection.clone(),
234 range_id: inputs.range_id,
235 previous_owner: range.owner().clone(),
236 expected_epoch: range.epoch(),
237 expected_version: range.version(),
238 watermark: inputs.watermark,
239 current_replicas: range.replicas().to_vec(),
240 eligible_candidates,
241 eligible_evidence,
242 ineligible_candidates,
243 })
244}
245
246pub fn execute_hot_mirror_failover(
248 catalog: &mut ShardOwnershipCatalog,
249 plan: &HotMirrorFailoverPlan,
250 candidate: &NodeIdentity,
251) -> Result<HotMirrorFailoverEvidence, HotMirrorFailoverError> {
252 let evidence = plan.evidence_for(candidate).ok_or_else(|| {
253 HotMirrorFailoverError::CandidateNotEligible {
254 collection: plan.collection.clone(),
255 range_id: plan.range_id,
256 candidate: candidate.clone(),
257 }
258 })?;
259 let request = TransitionRequest::new(
260 TransitionKind::Promote,
261 plan.collection.clone(),
262 plan.range_id,
263 plan.previous_owner.clone(),
264 plan.expected_epoch,
265 plan.expected_version,
266 candidate.clone(),
267 plan.watermark,
268 )
269 .with_evidence(evidence)
270 .with_replicas(
271 plan.current_replicas
272 .iter()
273 .filter(|replica| *replica != candidate)
274 .cloned(),
275 );
276
277 run_transition(catalog, &request)
278 .map(HotMirrorFailoverEvidence::from_transition)
279 .map_err(HotMirrorFailoverError::Transition)
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::cluster::identity::NodeIdentity;
286 use crate::cluster::ownership::{
287 CollectionId, PlacementMetadata, RangeBounds, RangeId, RangeOwnership, ShardKeyMode,
288 ShardOwnershipCatalog,
289 };
290 use crate::cluster::ownership_lease::{
291 admit_durable_write, DurableWriteReject, LeasedOwner, OwnershipLease, SupervisorTerm,
292 };
293 use crate::cluster::ownership_transition::{CatchUpEvidence, CommitWatermark};
294
295 fn collection(name: &str) -> CollectionId {
296 CollectionId::new(name).unwrap()
297 }
298
299 fn ident(cn: &str) -> NodeIdentity {
300 NodeIdentity::from_certificate_subject(cn).unwrap()
301 }
302
303 fn catalog_with(owner: &str, replicas: &[&str]) -> (ShardOwnershipCatalog, CollectionId) {
304 let orders = collection("orders");
305 let mut catalog = ShardOwnershipCatalog::new();
306 catalog
307 .apply_update(RangeOwnership::establish(
308 orders.clone(),
309 RangeId::new(1),
310 ShardKeyMode::Hash,
311 RangeBounds::full(),
312 ident(owner),
313 replicas.iter().map(|r| ident(r)).collect::<Vec<_>>(),
314 PlacementMetadata::with_replication_factor(3),
315 ))
316 .unwrap();
317 (catalog, orders)
318 }
319
320 #[test]
321 fn hot_mirror_failover_promotes_only_a_candidate_covering_the_watermark() {
322 let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b", "CN=node-c"]);
323 let watermark = CommitWatermark::new(7, 400);
324 let inputs = HotMirrorInputs::new(&catalog, orders.clone(), RangeId::new(1), watermark)
325 .with_catch_up(CatchUpEvidence::new(ident("CN=node-b"), 7, 400))
326 .with_catch_up(CatchUpEvidence::new(ident("CN=node-c"), 7, 399));
327
328 let plan = plan_hot_mirror_failover(&inputs).expect("range is known");
329 assert_eq!(plan.previous_owner(), &ident("CN=node-a"));
330 assert_eq!(plan.eligible_candidates(), &[ident("CN=node-b")]);
331 assert_eq!(
332 plan.ineligible_candidates()[0].candidate,
333 ident("CN=node-c")
334 );
335
336 let stale = execute_hot_mirror_failover(&mut catalog, &plan, &ident("CN=node-c"))
337 .expect_err("behind candidate is refused");
338 assert!(matches!(
339 stale,
340 HotMirrorFailoverError::CandidateNotEligible { .. }
341 ));
342 assert_eq!(
343 catalog.range(&orders, RangeId::new(1)).unwrap().owner(),
344 &ident("CN=node-a")
345 );
346
347 let old_epoch = catalog.range(&orders, RangeId::new(1)).unwrap().epoch();
348 let old_owner_lease = LeasedOwner::with_lease(OwnershipLease::grant(
349 SupervisorTerm::genesis(),
350 orders.clone(),
351 RangeId::new(1),
352 ident("CN=node-a"),
353 old_epoch,
354 0,
355 60_000,
356 ));
357
358 let evidence =
359 execute_hot_mirror_failover(&mut catalog, &plan, &ident("CN=node-b")).unwrap();
360 assert_eq!(evidence.range.collection, orders);
361 assert_eq!(evidence.previous_owner, ident("CN=node-a"));
362 assert_eq!(evidence.promoted_owner, ident("CN=node-b"));
363 assert_eq!(evidence.watermark, watermark);
364 assert_eq!(evidence.watermark_outcome, WatermarkOutcome::Covered);
365 assert!(evidence.epoch.new > evidence.epoch.previous);
366
367 let reject = admit_durable_write(
368 &catalog,
369 &old_owner_lease,
370 &ident("CN=node-a"),
371 &orders,
372 b"customer-1",
373 SupervisorTerm::genesis(),
374 1,
375 )
376 .unwrap_err();
377 assert!(matches!(reject, DurableWriteReject::StaleOwnership { .. }));
378 }
379}