use super::{DriverEvent, ExitState, FatalErrorReport, ThreadControl};
use crate::admin::HealthState;
use crate::checkpoint::Checkpointer;
use crate::error::{ErrorClass, FatalError, SourceError};
use crate::metrics::{CheckpointMetrics, Meter, PipelineMetrics, PipelineState, SourceMetrics};
use crate::record::PartitionId;
use crate::source::{DrainBarrier, LaneId, Source, SourceCtx, SourceEvent, SourceLane};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
const FAST_COMMIT_POLL: Duration = Duration::from_millis(1);
#[derive(Debug)]
pub(crate) enum ControllerSignal {
LanesDrained {
sink_deadline: Instant,
},
Finished(ControllerReport),
}
#[derive(Debug)]
pub(crate) struct ControllerReport {
pub state: ExitState,
pub final_watermarks: Vec<(PartitionId, i64)>,
}
pub(crate) struct ControllerContext<S: Source> {
pub source: S,
pub checkpointer: Checkpointer,
pub control_txs: Vec<crossbeam_channel::Sender<ThreadControl<S::Lane>>>,
pub events_rx: crossbeam_channel::Receiver<DriverEvent>,
pub to_main: crossbeam_channel::Sender<ControllerSignal>,
pub sink_drained_rx: crossbeam_channel::Receiver<()>,
pub shutdown: Arc<AtomicBool>,
pub health: Arc<HealthState>,
pub commit_interval: Duration,
pub drain_timeout: Duration,
pub event_poll_timeout: Duration,
pub max_pending_batches: usize,
pub stalled_fail_after: Duration,
pub checkpoint_metrics: CheckpointMetrics,
pub source_metrics: Arc<SourceMetrics>,
pub source_meter: Option<Meter>,
pub per_partition_detail: bool,
pub pipeline_metrics: PipelineMetrics,
}
pub(crate) fn run_controller<S: Source>(ctx: ControllerContext<S>) {
let ControllerContext {
mut source,
mut checkpointer,
control_txs,
events_rx,
to_main,
sink_drained_rx,
shutdown,
health,
commit_interval,
drain_timeout,
event_poll_timeout,
max_pending_batches,
stalled_fail_after,
checkpoint_metrics,
source_metrics,
source_meter,
per_partition_detail,
pipeline_metrics,
} = ctx;
let mut state = State {
assignment: HashMap::new(),
thread_load: vec![0usize; control_txs.len()],
paused: HashSet::new(),
pending_paused: HashSet::new(),
epoch: 0,
pending_commit: BTreeMap::new(),
committed: BTreeMap::new(),
failure: None,
};
if let Err(e) = source.open(
SourceCtx::new(checkpointer.handle())
.with_meter(source_meter)
.with_stage_metrics(Some(Arc::clone(&source_metrics)))
.with_partition_detail(per_partition_detail),
) {
state.failure = Some(FatalError {
component: "source".into(),
reason: format!("source open failed: {e}"),
});
}
let mut last_commit = Instant::now();
let mut fast_commit: BTreeMap<PartitionId, Instant> = BTreeMap::new();
let mut drained_exit = false;
while state.failure.is_none() && !shutdown.load(Ordering::Relaxed) {
while let Ok(event) = events_rx.try_recv() {
handle_driver_event(event, &mut source, &mut state);
}
if state.failure.is_some() {
break;
}
if last_commit.elapsed() >= commit_interval {
last_commit = Instant::now();
for tx in &control_txs {
let _ = tx.send(ThreadControl::FlushNow);
}
commit_cycle(
&mut source,
&mut checkpointer,
&mut state,
&checkpoint_metrics,
&health,
None,
);
for (partition, since) in checkpointer.stalled_partitions() {
let age = since.elapsed();
if age > stalled_fail_after {
state.failure.get_or_insert(FatalError {
component: "checkpoint".into(),
reason: format!(
"partition {} watermark stalled behind a failed batch for {age:?} \
(limit {stalled_fail_after:?}); a sink leg is permanently failing",
partition.0
),
});
break;
}
}
apply_pending_pressure(&mut source, &checkpointer, &mut state, max_pending_batches);
}
if state.failure.is_some() {
break;
}
let poll_timeout = if fast_commit.is_empty() {
event_poll_timeout
} else {
event_poll_timeout.min(FAST_COMMIT_POLL)
};
match source.poll_events(poll_timeout) {
Ok(SourceEvent::LanesAssigned(lanes)) => {
handle_assign(
lanes,
&mut source,
&mut checkpointer,
&mut state,
&control_txs,
&health,
&source_metrics,
&pipeline_metrics,
&checkpoint_metrics,
drain_timeout,
);
}
Ok(SourceEvent::LanesRevoked { lanes, barrier }) => {
handle_revoke(
lanes,
barrier,
&mut source,
&mut checkpointer,
&mut state,
&control_txs,
&source_metrics,
&checkpoint_metrics,
&health,
drain_timeout,
);
}
Ok(SourceEvent::LanesRetired { lanes }) => {
handle_retired::<S>(
lanes,
&mut checkpointer,
&mut state,
&control_txs,
&source_metrics,
);
}
Ok(SourceEvent::LanesAdded(lanes)) => {
handle_added::<S>(
lanes,
&mut source,
&mut checkpointer,
&mut state,
&control_txs,
&source_metrics,
);
}
Ok(SourceEvent::CommitReady { partitions }) => {
let mut threads: BTreeSet<usize> = BTreeSet::new();
for p in &partitions {
threads.extend(
state
.assignment
.values()
.filter(|(part, _)| part == p)
.map(|&(_, thread)| thread),
);
}
for thread in threads {
let _ = control_txs[thread].send(ThreadControl::FlushNow);
}
let until = Instant::now() + commit_interval;
for p in partitions {
fast_commit.entry(p).or_insert(until);
}
}
Ok(SourceEvent::Idle) => {}
Ok(SourceEvent::Drained) => {
tracing::info!("source drained; starting graceful completion drain");
drained_exit = true;
break;
}
Err(e) if is_fatal(&e) => {
state.failure = Some(FatalError {
component: "source".into(),
reason: format!("poll_events failed: {e}"),
});
}
Err(e) => {
tracing::warn!(error = %e, "retryable source control-plane error");
}
}
if !fast_commit.is_empty() && state.failure.is_none() {
let now = Instant::now();
fast_commit.retain(|_, &mut until| now < until);
if !fast_commit.is_empty() {
let chasing: BTreeSet<PartitionId> = fast_commit.keys().copied().collect();
commit_cycle(
&mut source,
&mut checkpointer,
&mut state,
&checkpoint_metrics,
&health,
Some(&chasing),
);
fast_commit.retain(|&p, _| state.assignment.values().any(|&(part, _)| part == p));
}
}
}
shutdown.store(true, Ordering::Relaxed);
pipeline_metrics.set_state(if state.failure.is_some() {
PipelineState::Failed
} else {
PipelineState::Draining
});
let deadline = Instant::now() + drain_timeout;
let barrier = DrainBarrier::new(control_txs.len());
for tx in &control_txs {
let _ = tx.send(ThreadControl::Shutdown {
barrier: barrier.clone(),
deadline,
});
}
if !barrier.wait(drain_timeout) {
tracing::error!(
remaining = barrier.remaining(),
"drivers did not finish draining before the deadline"
);
}
let _ = to_main.send(ControllerSignal::LanesDrained {
sink_deadline: deadline,
});
let sink_budget = deadline.saturating_duration_since(Instant::now()) + Duration::from_secs(2);
if sink_drained_rx.recv_timeout(sink_budget).is_err() {
tracing::error!("sink drain did not report before the deadline");
}
commit_cycle(
&mut source,
&mut checkpointer,
&mut state,
&checkpoint_metrics,
&health,
None,
);
let final_flush_failed = if let Err(e) = source.flush_commits() {
tracing::error!(error = %e, "final commit flush failed; offsets will replay");
true
} else {
false
};
if drained_exit && state.failure.is_none() {
let pending = checkpointer.max_pending();
if pending > 0 {
state.failure = Some(FatalError {
component: "source".into(),
reason: format!(
"source drained but unacknowledged batches remain (max {pending} on one \
partition); their data was not durably committed — rerun to complete"
),
});
} else if !state.pending_commit.is_empty() || final_flush_failed {
state.failure = Some(FatalError {
component: "source".into(),
reason: format!(
"source drained and every batch was acknowledged, but the final \
watermark commit did not persist ({} partition(s) uncommitted{}); \
the checkpoint holds stale offsets — rerun to replay the tail and \
complete",
state.pending_commit.len(),
if final_flush_failed {
", final flush failed"
} else {
""
},
),
});
}
if state.failure.is_some() {
pipeline_metrics.set_state(PipelineState::Failed);
}
}
let report = ControllerReport {
state: match state.failure {
Some(e) => ExitState::Failed(FatalErrorReport {
component: e.component,
reason: e.reason,
}),
None => ExitState::Completed,
},
final_watermarks: state.committed.into_iter().collect(),
};
let _ = to_main.send(ControllerSignal::Finished(report));
}
struct State {
assignment: HashMap<LaneId, (PartitionId, usize)>,
thread_load: Vec<usize>,
paused: HashSet<LaneId>,
pending_paused: HashSet<LaneId>,
epoch: u32,
pending_commit: BTreeMap<PartitionId, i64>,
committed: BTreeMap<PartitionId, i64>,
failure: Option<FatalError>,
}
fn is_fatal(e: &SourceError) -> bool {
let SourceError::Client { class, .. } = e;
*class == ErrorClass::Fatal
}
fn apply_pending_pressure<S: Source>(
source: &mut S,
checkpointer: &Checkpointer,
state: &mut State,
max_pending_batches: usize,
) {
let pending = checkpointer.max_pending();
if pending > max_pending_batches {
let to_pause: Vec<LaneId> = state
.assignment
.keys()
.filter(|l| !state.paused.contains(l) && !state.pending_paused.contains(l))
.copied()
.collect();
if to_pause.is_empty() {
return;
}
match source.pause(&to_pause) {
Ok(()) => {
state.pending_paused.extend(to_pause.iter().copied());
tracing::warn!(
pending,
limit = max_pending_batches,
lanes = to_pause.len(),
"checkpoint pending-batch limit exceeded; pausing lanes until it drains"
);
}
Err(e) => tracing::warn!(error = %e, "pending-pressure pause failed"),
}
} else if pending < max_pending_batches / 2 && !state.pending_paused.is_empty() {
let to_resume: Vec<LaneId> = state
.pending_paused
.iter()
.filter(|l| !state.paused.contains(l))
.copied()
.collect();
if to_resume.is_empty() {
state.pending_paused.clear();
return;
}
match source.resume(&to_resume) {
Ok(()) => {
tracing::warn!(
pending,
lanes = to_resume.len(),
"checkpoint pending pressure cleared; resuming lanes"
);
state.pending_paused.clear();
}
Err(e) => {
tracing::warn!(error = %e, "pending-pressure resume failed; retrying next tick")
}
}
}
}
fn handle_driver_event<S: Source>(event: DriverEvent, source: &mut S, state: &mut State) {
match event {
DriverEvent::PauseLanes { lanes } => {
let newly: Vec<LaneId> = lanes
.into_iter()
.filter(|l| !state.paused.contains(l) && state.assignment.contains_key(l))
.collect();
if newly.is_empty() {
return;
}
match source.pause(&newly) {
Ok(()) => state.paused.extend(newly),
Err(e) => tracing::warn!(error = %e, "source pause failed"),
}
}
DriverEvent::ResumeLanes { lanes } => {
let resumable: Vec<LaneId> = lanes
.into_iter()
.filter(|l| state.paused.contains(l))
.collect();
if resumable.is_empty() {
return;
}
match source.resume(&resumable) {
Ok(()) => {
for l in &resumable {
state.paused.remove(l);
}
}
Err(e) => tracing::warn!(error = %e, "source resume failed"),
}
}
DriverEvent::Fatal { thread, error } => {
tracing::error!(thread, error = %error, "pipeline thread reported fatal");
state.failure.get_or_insert(error);
}
}
}
fn commit_cycle<S: Source>(
source: &mut S,
checkpointer: &mut Checkpointer,
state: &mut State,
metrics: &CheckpointMetrics,
health: &HealthState,
only: Option<&BTreeSet<PartitionId>>,
) {
let stats = checkpointer.drain();
if stats.stale_epoch > 0 || stats.unknown > 0 {
tracing::debug!(
stale = stats.stale_epoch,
unknown = stats.unknown,
"discarded stale acknowledgements"
);
}
for (p, offset) in checkpointer.take_watermarks() {
let slot = state.pending_commit.entry(p).or_insert(offset);
*slot = (*slot).max(offset);
}
metrics.set_pending_max(checkpointer.max_pending());
let stalled = checkpointer.stalled_partitions();
let age = stalled
.iter()
.map(|(_, since)| since.elapsed())
.max()
.unwrap_or(Duration::ZERO);
metrics.set_watermark_age(age);
health.report_watermark(age, checkpointer.max_pending() > 0);
if state.pending_commit.is_empty() {
return;
}
let positions: Vec<(PartitionId, i64)> = state
.pending_commit
.iter()
.filter(|(p, _)| only.is_none_or(|f| f.contains(p)))
.map(|(&p, &o)| (p, o))
.collect();
if positions.is_empty() {
return;
}
let started = Instant::now();
match source.commit(&positions) {
Ok(()) => {
metrics.commit(true, started.elapsed());
for &(p, o) in &positions {
state.pending_commit.remove(&p);
state.committed.insert(p, o);
}
}
Err(e) if is_fatal(&e) => {
metrics.commit(false, started.elapsed());
state.failure.get_or_insert(FatalError {
component: "source".into(),
reason: format!("commit failed fatally: {e}"),
});
}
Err(e) => {
metrics.commit(false, started.elapsed());
tracing::warn!(error = %e, "commit failed; retrying next tick");
}
}
}
#[expect(
clippy::too_many_arguments,
reason = "controller state is deliberately spread across owners"
)]
fn handle_assign<S: Source>(
lanes: Vec<S::Lane>,
source: &mut S,
checkpointer: &mut Checkpointer,
state: &mut State,
control_txs: &[crossbeam_channel::Sender<ThreadControl<S::Lane>>],
health: &Arc<HealthState>,
source_metrics: &SourceMetrics,
pipeline_metrics: &PipelineMetrics,
checkpoint_metrics: &CheckpointMetrics,
drain_timeout: Duration,
) {
if !state.assignment.is_empty() {
tracing::warn!(
live_lanes = state.assignment.len(),
"assignment received while lanes are live; draining and revoking \
them first (sources should revoke before reassigning)"
);
let live: Vec<LaneId> = state.assignment.keys().copied().collect();
let barrier = DrainBarrier::new(live.len());
revoke_lanes(
&live,
barrier,
source,
checkpointer,
state,
control_txs,
checkpoint_metrics,
health,
drain_timeout,
);
}
state.epoch += 1;
let mut groups: HashMap<PartitionId, Vec<S::Lane>> = HashMap::new();
for lane in lanes {
groups.entry(lane.partition()).or_default().push(lane);
}
let partitions: Vec<PartitionId> = groups.keys().copied().collect();
checkpointer.begin_epoch(&partitions, state.epoch);
for (partition, group) in groups {
let thread = state
.thread_load
.iter()
.enumerate()
.min_by_key(|(_, load)| **load)
.map(|(i, _)| i)
.unwrap_or(0);
for lane in group {
let id = lane.id();
state.assignment.insert(id, (partition, thread));
state.thread_load[thread] += 1;
let _ = control_txs[thread].send(ThreadControl::AddLane(lane));
}
}
health.set_assignment_received(true);
source_metrics.rebalance_assigned();
source_metrics.set_lanes_active(state.assignment.len());
pipeline_metrics.set_state(PipelineState::Running);
}
fn handle_added<S: Source>(
lanes: Vec<S::Lane>,
source: &mut S,
checkpointer: &mut Checkpointer,
state: &mut State,
control_txs: &[crossbeam_channel::Sender<ThreadControl<S::Lane>>],
source_metrics: &SourceMetrics,
) {
if lanes.is_empty() {
return;
}
let mut groups: HashMap<PartitionId, Vec<S::Lane>> = HashMap::new();
for lane in lanes {
groups.entry(lane.partition()).or_default().push(lane);
}
let partitions: Vec<PartitionId> = groups.keys().copied().collect();
if let Err(e) = checkpointer.extend_epoch(&partitions) {
state.failure.get_or_insert(e);
return;
}
let mut added: Vec<LaneId> = Vec::new();
for (partition, group) in groups {
let thread = state
.thread_load
.iter()
.enumerate()
.min_by_key(|(_, load)| **load)
.map(|(i, _)| i)
.unwrap_or(0);
for lane in group {
let id = lane.id();
state.assignment.insert(id, (partition, thread));
state.thread_load[thread] += 1;
added.push(id);
let _ = control_txs[thread].send(ThreadControl::AddLane(lane));
}
}
if !state.pending_paused.is_empty() {
match source.pause(&added) {
Ok(()) => state.pending_paused.extend(added.iter().copied()),
Err(e) => {
tracing::warn!(error = %e, "pausing an added lane under pending pressure failed")
}
}
}
source_metrics.set_lanes_active(state.assignment.len());
}
#[expect(
clippy::too_many_arguments,
reason = "controller state is deliberately spread across owners"
)]
fn handle_revoke<S: Source>(
lanes: Vec<LaneId>,
barrier: DrainBarrier,
source: &mut S,
checkpointer: &mut Checkpointer,
state: &mut State,
control_txs: &[crossbeam_channel::Sender<ThreadControl<S::Lane>>],
source_metrics: &SourceMetrics,
checkpoint_metrics: &CheckpointMetrics,
health: &Arc<HealthState>,
drain_timeout: Duration,
) {
source_metrics.rebalance_revoked();
revoke_lanes(
&lanes,
barrier,
source,
checkpointer,
state,
control_txs,
checkpoint_metrics,
health,
drain_timeout,
);
source_metrics.set_lanes_active(state.assignment.len());
}
fn handle_retired<S: Source>(
lanes: Vec<LaneId>,
checkpointer: &mut Checkpointer,
state: &mut State,
control_txs: &[crossbeam_channel::Sender<ThreadControl<S::Lane>>],
source_metrics: &SourceMetrics,
) {
let mut by_thread: HashMap<usize, Vec<LaneId>> = HashMap::new();
for lane in &lanes {
if let Some(&(_, thread)) = state.assignment.get(lane) {
by_thread.entry(thread).or_default().push(*lane);
} else {
tracing::warn!(lane = lane.0, "retirement for an unassigned lane");
}
}
for (thread, subset) in by_thread {
let _ = control_txs[thread].send(ThreadControl::DropLanes { lanes: subset });
}
let mut retired_parts: HashSet<PartitionId> = lanes
.iter()
.filter_map(|l| state.assignment.get(l).map(|&(p, _)| p))
.collect();
for lane in &lanes {
if let Some((_, thread)) = state.assignment.remove(lane) {
state.thread_load[thread] = state.thread_load[thread].saturating_sub(1);
}
state.paused.remove(lane);
state.pending_paused.remove(lane);
}
let live_partitions: HashSet<PartitionId> =
state.assignment.values().map(|&(p, _)| p).collect();
let to_revoke: Vec<PartitionId> = retired_parts
.drain()
.filter(|p| !live_partitions.contains(p))
.collect();
checkpointer.revoke(&to_revoke);
source_metrics.set_lanes_active(state.assignment.len());
}
#[expect(
clippy::too_many_arguments,
reason = "controller state is deliberately spread across owners"
)]
fn revoke_lanes<S: Source>(
lanes: &[LaneId],
barrier: DrainBarrier,
source: &mut S,
checkpointer: &mut Checkpointer,
state: &mut State,
control_txs: &[crossbeam_channel::Sender<ThreadControl<S::Lane>>],
checkpoint_metrics: &CheckpointMetrics,
health: &Arc<HealthState>,
drain_timeout: Duration,
) {
let deadline = Instant::now() + drain_timeout;
let mut by_thread: HashMap<usize, Vec<LaneId>> = HashMap::new();
for lane in lanes {
match state.assignment.get(lane) {
Some(&(_, thread)) => by_thread.entry(thread).or_default().push(*lane),
None => {
tracing::warn!(lane = lane.0, "revocation for an unassigned lane");
barrier.arrive();
}
}
}
for (thread, subset) in by_thread {
let _ = control_txs[thread].send(ThreadControl::StopLanes {
lanes: subset,
barrier: barrier.clone(),
deadline,
});
}
if !barrier.wait(drain_timeout) {
tracing::error!(
remaining = barrier.remaining(),
"lane drain did not finish before the deadline; unflushed \
records will replay"
);
}
commit_cycle(
source,
checkpointer,
state,
checkpoint_metrics,
health,
None,
);
if let Err(e) = source.flush_commits() {
tracing::warn!(error = %e, "flush of stored commits failed during revocation");
}
let mut revoked_parts: HashSet<PartitionId> = lanes
.iter()
.filter_map(|l| state.assignment.get(l).map(|&(p, _)| p))
.collect();
for lane in lanes {
if let Some((_, thread)) = state.assignment.remove(lane) {
state.thread_load[thread] = state.thread_load[thread].saturating_sub(1);
}
state.paused.remove(lane);
state.pending_paused.remove(lane);
}
let live_partitions: HashSet<PartitionId> =
state.assignment.values().map(|&(p, _)| p).collect();
let to_revoke: Vec<PartitionId> = revoked_parts
.drain()
.filter(|p| !live_partitions.contains(p))
.collect();
checkpointer.revoke(&to_revoke);
}