1use {
14 crate::{
15 numeric::nonnegative_i64_to_u64,
16 scenario::{FailoverSpec, Stack},
17 },
18 anyhow::{Context, Result, anyhow},
19 chrono::Utc,
20 k8s_openapi::api::core::v1::Pod,
21 krabka_client_core::{
22 Client as KafkaClient, ClientFrameMax, ConnectionDispatchQueueCapacity,
23 security::ClientSecurity,
24 },
25 krabka_protocol::owned::metadata_response::MetadataResponse,
26 krabka_units::convert::TimeExt,
27 kube::{
28 Client as KubeClient,
29 api::{Api, DeleteParams, ListParams},
30 },
31 std::{
32 future::Future,
33 sync::{
34 Arc,
35 atomic::{AtomicU64, Ordering},
36 },
37 },
38 tokio::{
39 task::JoinSet,
40 time::{Instant, sleep_until},
41 },
42 tracing::{info, warn},
43};
44
45pub(crate) struct FaultConfig {
46 pub stack: Stack,
47 pub namespace: String,
48 pub bootstrap: String,
49 pub topic: String,
50 pub security: Option<ClientSecurity>,
51 pub dispatch_queue_capacity: ConnectionDispatchQueueCapacity,
52 pub frame_max: ClientFrameMax,
53}
54
55#[derive(Debug, PartialEq)]
56pub(crate) struct KillEvidence {
57 pub pod: String,
58 pub at_ms: u64,
59}
60
61#[derive(Debug, PartialEq)]
62pub(crate) enum FaultOutcome {
63 Cancelled,
64 TaskFailed(String),
65 ClientUnavailable(String),
66 Finished {
67 leader_lookup_error: Option<String>,
68 deletion: Result<KillEvidence, String>,
69 },
70}
71
72impl FaultOutcome {
73 pub(crate) fn killed_at_ms(&self) -> Option<u64> {
74 match self {
75 Self::Finished {
76 deletion: Ok(evidence),
77 ..
78 } => Some(evidence.at_ms),
79 _ => None,
80 }
81 }
82
83 pub(crate) fn note(&self) -> Option<String> {
84 match self {
85 Self::Cancelled => Some("failover: cancelled at measurement end".into()),
86 Self::TaskFailed(error) => Some(format!("failover: fault task failed: {error}")),
87 Self::ClientUnavailable(error) => {
88 Some(format!("failover: in-cluster client unavailable: {error}"))
89 }
90 Self::Finished {
91 leader_lookup_error,
92 deletion,
93 } => {
94 let fallback = leader_lookup_error
95 .as_ref()
96 .map_or_else(String::new, |error| {
97 format!("leader lookup failed; using first broker pod: {error}; ")
98 });
99 match deletion {
100 Ok(evidence) if leader_lookup_error.is_some() => Some(format!(
101 "failover: {fallback}killed broker {}",
102 evidence.pod
103 )),
104 Ok(_) => None,
105 Err(error) => Some(format!("failover: {fallback}broker kill failed: {error}")),
106 }
107 }
108 }
109 }
110}
111
112pub(crate) struct FaultTask {
113 tasks: JoinSet<FaultOutcome>,
114}
115
116impl FaultTask {
117 pub(crate) fn spawn(
118 spec: &FailoverSpec,
119 cfg: FaultConfig,
120 started_at: Instant,
121 kill_at: Arc<AtomicU64>,
122 ) -> Self {
123 let target_leader = spec.target == "partition0_leader";
124 let deadline = started_at + spec.kill_after.to_std();
125 Self::schedule(deadline, async move {
126 Box::pin(execute_fault(
127 target_leader,
128 try_client(),
129 partition0_leader_from_metadata(
130 &cfg.bootstrap,
131 &cfg.topic,
132 cfg.security,
133 cfg.dispatch_queue_capacity,
134 cfg.frame_max,
135 ),
136 |client, leader| async move {
137 kill_broker_pod(&client, cfg.stack, &cfg.namespace, leader).await
138 },
139 kill_at,
140 ))
141 .await
142 })
143 }
144
145 fn schedule(
146 deadline: Instant,
147 operation: impl Future<Output = FaultOutcome> + Send + 'static,
148 ) -> Self {
149 let mut tasks = JoinSet::new();
150 tasks.spawn(async move {
151 sleep_until(deadline).await;
152 operation.await
153 });
154 Self { tasks }
155 }
156
157 pub(crate) async fn stop(mut self) -> FaultOutcome {
158 let result = if let Some(result) = self.tasks.try_join_next() {
160 Some(result)
161 } else {
162 self.tasks.abort_all();
163 self.tasks.join_next().await
164 };
165 match result {
166 Some(Ok(outcome)) => outcome,
167 Some(Err(error)) if error.is_cancelled() => FaultOutcome::Cancelled,
168 Some(Err(error)) => FaultOutcome::TaskFailed(error.to_string()),
169 None => FaultOutcome::TaskFailed("fault task missing".into()),
170 }
171 }
172}
173
174async fn execute_fault<C, D, L, K, F>(
175 target_leader: bool,
176 discover: D,
177 lookup: L,
178 delete: F,
179 kill_at: Arc<AtomicU64>,
180) -> FaultOutcome
181where
182 D: Future<Output = Result<C>>,
183 L: Future<Output = Result<i32>>,
184 K: Future<Output = Result<String>>,
185 F: FnOnce(C, Option<i32>) -> K,
186{
187 let client = match discover.await {
188 Ok(client) => client,
189 Err(error) => {
190 warn!(%error, "failover: in-cluster client unavailable");
191 return FaultOutcome::ClientUnavailable(error.to_string());
192 }
193 };
194 let (leader_id, leader_lookup_error) = if target_leader {
195 match lookup.await {
196 Ok(id) => (Some(id), None),
197 Err(error) => {
198 warn!(%error, "failover: leader lookup failed; using first broker pod");
199 (None, Some(error.to_string()))
200 }
201 }
202 } else {
203 (None, None)
204 };
205 let deletion = match delete(client, leader_id).await {
206 Ok(pod) => {
207 let at_ms = nonnegative_i64_to_u64(Utc::now().timestamp_millis());
208 kill_at.store(at_ms, Ordering::SeqCst);
209 info!(%pod, "failover: killed broker");
210 Ok(KillEvidence { pod, at_ms })
211 }
212 Err(error) => {
213 warn!(%error, "failover: broker kill failed");
214 Err(error.to_string())
215 }
216 };
217 FaultOutcome::Finished {
218 leader_lookup_error,
219 deletion,
220 }
221}
222
223pub async fn try_client() -> Result<KubeClient> {
231 KubeClient::try_default()
232 .await
233 .context("build in-cluster kube client")
234}
235
236pub async fn partition0_leader_from_metadata(
241 bootstrap: &str,
242 topic: &str,
243 security: Option<ClientSecurity>,
244 dispatch_queue_capacity: ConnectionDispatchQueueCapacity,
245 frame_max: ClientFrameMax,
246) -> Result<i32> {
247 let client = KafkaClient::builder()
248 .bootstrap(bootstrap.to_owned())
249 .client_id("bench-failover-targeter")
250 .dispatch_queue_capacity(dispatch_queue_capacity.get())
251 .frame_max(frame_max.size())
252 .maybe_security(security)
253 .build()
254 .await
255 .context("build metadata client")?;
256 let md = client
257 .refresh_metadata()
258 .await
259 .context("refresh metadata")?;
260 let leader = partition0_leader_id(&md, topic)
261 .ok_or_else(|| anyhow!("metadata did not contain {topic} partition 0 leader"))?;
262 client.close();
263 Ok(leader)
264}
265
266pub async fn kill_broker_pod(
274 client: &KubeClient,
275 stack: Stack,
276 namespace: &str,
277 leader_id: Option<i32>,
278) -> Result<String> {
279 let prefix = stack.broker_pod_regex().trim_start_matches('^');
280
281 let pods: Api<Pod> = Api::namespaced(client.clone(), namespace);
282 let list = pods
283 .list(&ListParams::default())
284 .await
285 .context("list pods in namespace")?;
286 let mut names: Vec<String> = list
287 .items
288 .iter()
289 .filter_map(|p| p.metadata.name.clone())
290 .filter(|n| n.starts_with(prefix))
291 .collect();
292 names.sort();
293 let Some(target) = choose_target_pod(&names, stack, leader_id) else {
294 return Err(anyhow!("no broker pod matched prefix {prefix}"));
295 };
296
297 let dp = DeleteParams::default().grace_period(0);
298 pods.delete(&target, &dp)
299 .await
300 .with_context(|| format!("delete pod {target}"))?;
301
302 Ok(target)
303}
304
305pub async fn kill_first_broker(
311 client: &KubeClient,
312 stack: Stack,
313 namespace: &str,
314) -> Result<String> {
315 kill_broker_pod(client, stack, namespace, None).await
316}
317
318fn partition0_leader_id(md: &MetadataResponse, topic: &str) -> Option<i32> {
319 md.topics
320 .iter()
321 .find(|t| t.name.as_deref() == Some(topic))
322 .and_then(|t| t.partitions.iter().find(|p| p.partition_index == 0))
323 .map(|p| p.leader_id)
324 .filter(|id| *id >= 0)
325}
326
327fn choose_target_pod(names: &[String], stack: Stack, leader_id: Option<i32>) -> Option<String> {
328 let prefix = stack.broker_pod_regex().trim_start_matches('^');
329 let mut matching: Vec<&String> = names.iter().filter(|n| n.starts_with(prefix)).collect();
330 matching.sort();
331
332 if let Some(id) = leader_id {
333 let broker_suffix = format!("-{id}");
334 let nodepool_suffix = format!("-{id}-0");
335 if let Some(name) = matching
336 .iter()
337 .find(|n| n.ends_with(&broker_suffix) || n.ends_with(&nodepool_suffix))
338 {
339 return Some((*name).clone());
340 }
341 }
342
343 matching.first().map(|n| (*n).clone())
344}
345
346#[cfg(test)]
347mod tests {
348 use {
349 super::*,
350 krabka_protocol::owned::metadata_response::{
351 MetadataResponse, MetadataResponsePartition, MetadataResponseTopic,
352 },
353 std::{sync, time::Duration},
354 tokio::{sync::oneshot, task, time},
355 };
356
357 #[tokio::test(start_paused = true)]
358 async fn stopping_before_fault_deadline_prevents_later_deletion() {
359 let kill_at = Arc::new(AtomicU64::new(0));
360 let deletes = Arc::new(AtomicU64::new(0));
361 let observed = sync::Arc::clone(&deletes);
362 let marker = sync::Arc::clone(&kill_at);
363 let task = FaultTask::schedule(Instant::now() + Duration::from_secs(60), async move {
364 execute_fault(
365 true,
366 async { Ok(()) },
367 async { Ok(2) },
368 |(), _| async move {
369 observed.fetch_add(1, Ordering::SeqCst);
370 Ok("broker-2".into())
371 },
372 marker,
373 )
374 .await
375 });
376 task::yield_now().await;
377 time::advance(Duration::from_secs(5)).await;
378 assert2::assert!(task.stop().await == FaultOutcome::Cancelled);
379 time::advance(Duration::from_secs(120)).await;
380 assert2::assert!(deletes.load(Ordering::SeqCst) == 0);
381 assert2::assert!(kill_at.load(Ordering::SeqCst) == 0);
382 }
383
384 #[tokio::test(start_paused = true)]
385 async fn dropping_fault_owner_cancels_scheduled_operation() {
386 let operations = Arc::new(AtomicU64::new(0));
387 let observed = sync::Arc::clone(&operations);
388 let task = FaultTask::schedule(Instant::now() + Duration::from_secs(30), async move {
389 observed.fetch_add(1, Ordering::SeqCst);
390 FaultOutcome::ClientUnavailable("injected".into())
391 });
392 task::yield_now().await;
393 drop(task);
394 task::yield_now().await;
395 time::advance(Duration::from_secs(60)).await;
396 assert2::assert!(operations.load(Ordering::SeqCst) == 0);
397 }
398
399 #[tokio::test(start_paused = true)]
400 async fn stopping_cancels_and_joins_an_in_flight_fault() {
401 let kill_at = Arc::new(AtomicU64::new(0));
402 let marker = sync::Arc::clone(&kill_at);
403 let (entered_tx, entered_rx) = oneshot::channel();
404 let (release_tx, release_rx) = oneshot::channel();
405 let task = FaultTask::schedule(Instant::now(), async move {
406 execute_fault(
407 true,
408 async { Ok(()) },
409 async { Ok(2) },
410 |(), _| async move {
411 assert2::assert!(entered_tx.send(()).is_ok());
412 release_rx.await.context("release injected deletion")?;
413 Ok("broker".into())
414 },
415 marker,
416 )
417 .await
418 });
419 entered_rx.await.expect("fault operation entered");
420 assert2::assert!(task.stop().await == FaultOutcome::Cancelled);
421 assert2::assert!(release_tx.send(()).is_err());
423 assert2::assert!(kill_at.load(Ordering::SeqCst) == 0);
424 }
425
426 #[tokio::test]
427 async fn fault_outcomes_preserve_failures_and_only_mark_successful_deletions() {
428 for (discovery_fails, lookup_fails, deletion_fails, expected_note) in [
429 (
430 true,
431 false,
432 false,
433 Some("failover: in-cluster client unavailable: discovery"),
434 ),
435 (
436 false,
437 true,
438 false,
439 Some(
440 "failover: leader lookup failed; using first broker pod: lookup; killed broker broker",
441 ),
442 ),
443 (
444 false,
445 false,
446 true,
447 Some("failover: broker kill failed: deletion"),
448 ),
449 (
450 false,
451 true,
452 true,
453 Some(
454 "failover: leader lookup failed; using first broker pod: lookup; broker kill failed: deletion",
455 ),
456 ),
457 (false, false, false, None),
458 ] {
459 let kill_at = Arc::new(AtomicU64::new(0));
460 let deletes = Arc::new(AtomicU64::new(0));
461 let observed = sync::Arc::clone(&deletes);
462 let outcome = execute_fault(
463 true,
464 async {
465 if discovery_fails {
466 Err(anyhow!("discovery"))
467 } else {
468 Ok(())
469 }
470 },
471 async {
472 if lookup_fails {
473 Err(anyhow!("lookup"))
474 } else {
475 Ok(2)
476 }
477 },
478 |(), leader| async move {
479 assert2::assert!(leader == if lookup_fails { None } else { Some(2) });
480 observed.fetch_add(1, Ordering::SeqCst);
481 if deletion_fails {
482 Err(anyhow!("deletion"))
483 } else {
484 Ok("broker".into())
485 }
486 },
487 sync::Arc::clone(&kill_at),
488 )
489 .await;
490 assert2::assert!(outcome.note().as_deref() == expected_note);
491 if discovery_fails {
492 assert2::assert!(outcome == FaultOutcome::ClientUnavailable("discovery".into()));
493 assert2::assert!(deletes.load(Ordering::SeqCst) == 0);
494 } else {
495 let deletion = if deletion_fails {
496 Err("deletion".into())
497 } else {
498 Ok(KillEvidence {
499 pod: "broker".into(),
500 at_ms: kill_at.load(Ordering::SeqCst),
501 })
502 };
503 assert2::assert!(
504 outcome
505 == FaultOutcome::Finished {
506 leader_lookup_error: lookup_fails.then(|| "lookup".into()),
507 deletion,
508 }
509 );
510 assert2::assert!(deletes.load(Ordering::SeqCst) == 1);
511 }
512 assert2::assert!(
513 (kill_at.load(Ordering::SeqCst) != 0) == (!discovery_fails && !deletion_fails)
514 );
515 assert2::assert!(
516 outcome.killed_at_ms().is_some() == (!discovery_fails && !deletion_fails)
517 );
518 }
519 }
520
521 #[tokio::test(start_paused = true)]
522 async fn completed_fault_evidence_survives_stop() {
523 let task = FaultTask::schedule(Instant::now(), async {
524 FaultOutcome::Finished {
525 leader_lookup_error: None,
526 deletion: Ok(KillEvidence {
527 pod: "broker".into(),
528 at_ms: 42,
529 }),
530 }
531 });
532 task::yield_now().await;
533 task::yield_now().await;
534 assert2::assert!(task.stop().await.killed_at_ms() == Some(42));
535 }
536
537 #[test]
538 fn partition0_leader_is_read_from_metadata() {
539 let md = MetadataResponse {
540 topics: vec![MetadataResponseTopic {
541 name: Some("bench-topic".into()),
542 partitions: vec![
543 MetadataResponsePartition {
544 partition_index: 1,
545 leader_id: 0,
546 ..Default::default()
547 },
548 MetadataResponsePartition {
549 partition_index: 0,
550 leader_id: 2,
551 ..Default::default()
552 },
553 ],
554 ..Default::default()
555 }],
556 ..Default::default()
557 };
558
559 assert2::assert!(partition0_leader_id(&md, "bench-topic") == Some(2));
560 }
561
562 #[test]
563 fn target_pod_uses_actual_leader_id_for_each_stack_naming_shape() {
564 let krabka_multi = vec![
565 "demo-broker-0-0".to_owned(),
566 "demo-broker-1-0".to_owned(),
567 "demo-broker-2-0".to_owned(),
568 ];
569 assert2::assert!(
570 choose_target_pod(&krabka_multi, Stack::Krabka, Some(1)).as_deref()
571 == Some("demo-broker-1-0")
572 );
573
574 let krabka_single_pool = vec![
575 "demo-brokers-0".to_owned(),
576 "demo-brokers-1".to_owned(),
577 "demo-brokers-2".to_owned(),
578 ];
579 assert2::assert!(
580 choose_target_pod(&krabka_single_pool, Stack::Krabka, Some(2)).as_deref()
581 == Some("demo-brokers-2")
582 );
583
584 let kafka = vec![
585 "demo-kafka-0".to_owned(),
586 "demo-kafka-1".to_owned(),
587 "demo-kafka-2".to_owned(),
588 ];
589 assert2::assert!(
590 choose_target_pod(&kafka, Stack::Kafka, Some(1)).as_deref() == Some("demo-kafka-1")
591 );
592 }
593
594 #[test]
595 fn target_pod_falls_back_to_first_matching_broker_when_leader_unknown() {
596 let pods = vec![
597 "demo-kafka-2".to_owned(),
598 "demo-kafka-0".to_owned(),
599 "demo-kafka-1".to_owned(),
600 ];
601
602 assert2::assert!(
603 choose_target_pod(&pods, Stack::Kafka, None).as_deref() == Some("demo-kafka-0")
604 );
605 }
606}