use super::runtime::StopRequest;
use super::{DriverEvent, ExitState, FatalErrorReport, ThreadControl};
use crate::admin::HealthState;
use crate::backpressure::InflightBudget;
use crate::checkpoint::Checkpointer;
use crate::clock::{Clock, Stopwatch, SystemClock};
use crate::error::{ErrorClass, FatalError, SourceError};
use crate::feed::STOP_RESERVE;
use crate::hash::{FastMap, FastSet};
use crate::metrics::{
CheckpointMetrics, InflightBudgetMetrics, Meter, PipelineMetrics, PipelineState, SourceMetrics,
};
use crate::record::PartitionId;
use crate::sink::SinkFailures;
use crate::source::{DrainBarrier, LaneId, Source, SourceCtx, SourceEvent, SourceLane};
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroUsize;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
pub(crate) const FAST_COMMIT_POLL: Duration = Duration::from_millis(1);
pub(crate) const CLOSE_SHARE: Duration = Duration::from_secs(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 close_by: Instant,
}
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 stop: Arc<StopRequest>,
pub shutdown: Arc<AtomicBool>,
pub stop_clock: Arc<dyn Clock + Send + Sync>,
pub clock: Arc<dyn Clock + Send + Sync>,
pub close_share: Duration,
pub health: Arc<HealthState>,
pub commit_interval: Duration,
pub chase_window: Duration,
pub drain_timeout: Duration,
pub event_poll_timeout: Duration,
pub stalled_fail_after: Duration,
pub sink_failures: SinkFailures,
pub checkpoint_metrics: CheckpointMetrics,
pub budget: Arc<InflightBudget>,
pub budget_metrics: InflightBudgetMetrics,
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,
stop,
shutdown,
stop_clock,
clock,
close_share,
health,
commit_interval,
chase_window,
drain_timeout,
event_poll_timeout,
stalled_fail_after,
sink_failures,
checkpoint_metrics,
budget,
budget_metrics,
source_metrics,
source_meter,
per_partition_detail,
pipeline_metrics,
} = ctx;
let mut state = State {
assignment: FastMap::default(),
thread_load: vec![0usize; control_txs.len()],
paused: FastSet::default(),
epoch: 0,
pending_commit: BTreeMap::new(),
committed: BTreeMap::new(),
revoked_committed: BTreeMap::new(),
failure: None,
};
let stop_signal = crate::source::StopSignal::new(stop.flag());
if let Err(e) = source.open(
SourceCtx::new(checkpointer.handle())
.with_stop(stop_signal.clone())
.with_meter(source_meter)
.with_stage_metrics(Some(Arc::clone(&source_metrics)))
.with_partition_detail(per_partition_detail)
.with_threads(NonZeroUsize::new(control_txs.len())),
) {
state.failure = Some(FatalError {
component: "source".into(),
reason: format!("source open failed: {e}"),
});
}
let mut last_commit = clock.now();
let mut fast_commit: BTreeMap<PartitionId, Instant> = BTreeMap::new();
let mut after_window: BTreeMap<PartitionId, Option<i64>> = BTreeMap::new();
let mut drained_exit = false;
let opened = state.failure.is_none();
let mut stopping: Option<StopTimes> = None;
while state.failure.is_none() {
if stopping.is_none() && stop.is_set() {
let trigger = stop.triggered_at().unwrap_or_else(|| stop_clock.now());
stopping = Some(StopTimes::new(trigger, drain_timeout));
pipeline_metrics.set_state(PipelineState::Draining);
}
if let Some(times) = &stopping
&& (stop_clock.now() >= times.reserve_at || !source.reading_on_stop())
{
break;
}
let revoke_budget = stopping.as_ref().map_or(drain_timeout, |times| {
stop_budget(stop_clock.now(), times.reserve_at, drain_timeout)
});
let watch = StopWatch {
stop: &stop,
shutdown: &shutdown,
clock: stop_clock.as_ref(),
drain_timeout,
};
while let Ok(event) = events_rx.try_recv() {
handle_driver_event(event, &mut source, &mut state);
}
if state.failure.is_some() {
break;
}
budget_metrics.set_inflight_bytes(budget.usage());
harvest(&mut checkpointer, &mut state);
let now = clock.now();
if now.saturating_duration_since(last_commit) >= commit_interval {
last_commit = now;
for tx in &control_txs {
let _ = tx.send(ThreadControl::FlushNow);
}
commit_cycle(
&mut source,
&mut checkpointer,
&mut state,
&checkpoint_metrics,
&health,
None,
false,
);
let now = checkpointer.now();
for (partition, since) in checkpointer.stalled_partitions() {
let age = now.saturating_duration_since(since);
if age > stalled_fail_after {
state.failure.get_or_insert(FatalError {
component: "checkpoint".into(),
reason: with_sink_failures(
format!(
"partition {} watermark stalled behind a failed batch for \
{age:?} (limit {stalled_fail_after:?})",
partition.0
),
&sink_failures,
),
});
break;
}
}
}
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,
revoke_budget,
watch,
);
fast_commit.clear();
after_window.clear();
if stopping.is_some() {
pipeline_metrics.set_state(PipelineState::Draining);
}
}
Ok(SourceEvent::LanesRevoked { lanes, barrier }) => {
handle_revoke(
lanes,
barrier,
&mut source,
&mut checkpointer,
&mut state,
&control_txs,
&source_metrics,
&checkpoint_metrics,
&health,
revoke_budget,
watch,
);
}
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 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 = clock.now() + chase_window;
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() || !after_window.is_empty()) && state.failure.is_none() {
let now = clock.now();
fast_commit.retain(|&p, &mut until| {
let open = now < until;
if !open {
after_window.entry(p).or_insert(None);
}
open
});
if !after_window.is_empty() {
harvest(&mut checkpointer, &mut state);
}
let advanced = after_window.iter().filter(|(p, sent)| {
state
.pending_commit
.get(p)
.is_some_and(|&o| Some(o) != **sent)
});
let chasing: BTreeSet<PartitionId> = fast_commit
.keys()
.chain(advanced.map(|(p, _)| p))
.copied()
.collect();
if !chasing.is_empty() {
commit_cycle(
&mut source,
&mut checkpointer,
&mut state,
&checkpoint_metrics,
&health,
Some(&chasing),
false,
);
for p in &chasing {
if let (Some(sent), Some(&o)) =
(after_window.get_mut(p), state.pending_commit.get(p))
{
*sent = Some(o);
}
}
}
let assigned = |p: PartitionId| state.assignment.values().any(|&(part, _)| part == p);
fast_commit.retain(|&p, _| assigned(p));
after_window.retain(|&p, _| assigned(p));
}
}
shutdown.store(true, Ordering::Relaxed);
stop_signal.close();
pipeline_metrics.set_state(if state.failure.is_some() {
PipelineState::Failed
} else {
PipelineState::Draining
});
if opened
&& !drained_exit
&& let Err(e) = source.stop_reading()
{
tracing::warn!(error = %e, "the source could not hand back the units it holds");
}
let deadline =
stopping.map_or_else(|| stop_clock.now() + drain_timeout, |times| times.deadline);
let lanes_by = lanes_deadline(stop_clock.now(), deadline, close_share);
let barrier = DrainBarrier::new(control_txs.len());
for tx in &control_txs {
let _ = tx.send(ThreadControl::Shutdown {
barrier: barrier.clone(),
deadline: lanes_by,
});
}
if !barrier.wait(lanes_by.saturating_duration_since(stop_clock.now())) {
tracing::error!(
remaining = barrier.remaining(),
"drivers did not finish draining before the deadline"
);
}
while let Ok(event) = events_rx.try_recv() {
if let DriverEvent::Fatal { thread, error } = event {
tracing::error!(thread, error = %error, "pipeline thread reported fatal");
state.failure.get_or_insert(error);
}
}
if state.failure.is_some() {
pipeline_metrics.set_state(PipelineState::Failed);
}
let _ = to_main.send(ControllerSignal::LanesDrained {
sink_deadline: lanes_by,
});
let sink_budget = lanes_by.saturating_duration_since(stop_clock.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,
true,
);
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: with_sink_failures(
format!(
"source drained but unacknowledged batches remain (max {pending} on \
one partition); their data was not durably committed — rerun to \
complete"
),
&sink_failures,
),
});
} 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(),
close_by: deadline,
};
let _ = to_main.send(ControllerSignal::Finished(report));
}
const STOP_POLL: Duration = Duration::from_millis(10);
#[derive(Clone, Copy)]
struct StopWatch<'a> {
stop: &'a StopRequest,
shutdown: &'a AtomicBool,
clock: &'a (dyn Clock + Send + Sync),
drain_timeout: Duration,
}
impl StopWatch<'_> {
fn ends_the_wait<S: Source>(&self, source: &mut S) -> bool {
if !self.stop.is_set() {
return false;
}
let trigger = self.stop.triggered_at().unwrap_or_else(|| self.clock.now());
let times = StopTimes::new(trigger, self.drain_timeout);
self.clock.now() >= times.reserve_at || !source.reading_on_stop()
}
}
fn wait_revocation<S: Source>(
barrier: &DrainBarrier,
deadline: Instant,
source: &mut S,
watch: StopWatch<'_>,
) -> bool {
loop {
let left = deadline.saturating_duration_since(watch.clock.now());
if barrier.wait(left.min(STOP_POLL)) {
return true;
}
if watch.clock.now() >= deadline {
return false;
}
source.renew_held();
if !watch.shutdown.load(Ordering::Relaxed) && watch.ends_the_wait(source) {
watch.shutdown.store(true, Ordering::Relaxed);
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct StopTimes {
reserve_at: Instant,
deadline: Instant,
}
impl StopTimes {
fn new(trigger: Instant, drain_timeout: Duration) -> StopTimes {
let deadline = trigger + drain_timeout;
let reserve_at = deadline
.checked_sub(STOP_RESERVE)
.map_or(trigger, |at| at.max(trigger));
StopTimes {
reserve_at,
deadline,
}
}
}
fn stop_budget(now: Instant, deadline: Instant, fresh: Duration) -> Duration {
fresh.min(deadline.saturating_duration_since(now))
}
fn lanes_deadline(now: Instant, deadline: Instant, close_share: Duration) -> Instant {
deadline
.checked_sub(close_share)
.map_or(now, |by| by.max(now))
}
struct State {
assignment: FastMap<LaneId, (PartitionId, usize)>,
thread_load: Vec<usize>,
paused: FastSet<LaneId>,
epoch: u32,
pending_commit: BTreeMap<PartitionId, i64>,
committed: BTreeMap<PartitionId, i64>,
revoked_committed: BTreeMap<PartitionId, i64>,
failure: Option<FatalError>,
}
fn with_sink_failures(reason: String, sink_failures: &SinkFailures) -> String {
match sink_failures.describe() {
Some(sinks) => format!("{reason}; {sinks}"),
None => reason,
}
}
fn is_fatal(e: &SourceError) -> bool {
let SourceError::Client { class, .. } = e;
*class == ErrorClass::Fatal
}
fn pending_gate(
checkpointer: &Checkpointer,
partition: PartitionId,
epoch: u32,
) -> Option<crate::checkpoint::PendingGate> {
let gate = checkpointer
.advance_handle(partition)
.map(|advanced| crate::checkpoint::PendingGate { epoch, advanced });
if gate.is_none() {
tracing::warn!(
partition = partition.0,
"no advance counter for an assigned partition; its lanes run ungated"
);
}
gate
}
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 harvest(checkpointer: &mut Checkpointer, state: &mut State) {
let stats = checkpointer.drain();
if stats.stale_epoch > 0 || stats.unknown > 0 {
tracing::debug!(
stale = stats.stale_epoch,
unknown = stats.unknown,
"discarded stale acknowledgments"
);
}
for (p, offset) in checkpointer.take_watermarks() {
let slot = state.pending_commit.entry(p).or_insert(offset);
*slot = (*slot).max(offset);
}
}
fn commit_cycle<S: Source>(
source: &mut S,
checkpointer: &mut Checkpointer,
state: &mut State,
metrics: &CheckpointMetrics,
health: &HealthState,
only: Option<&BTreeSet<PartitionId>>,
final_pass: bool,
) {
harvest(checkpointer, state);
let max_pending = checkpointer.max_pending();
metrics.set_pending_max(max_pending);
if only.is_none() && metrics.publishes_partition_detail() {
let pending = checkpointer.pending_by_partition();
for &(partition, n) in &pending {
metrics.set_partition_pending(partition, n);
}
let live: Vec<PartitionId> = pending.iter().map(|&(p, _)| p).collect();
metrics.retain_partitions(&live);
}
let stalled = checkpointer.stalled_partitions();
let now = checkpointer.now();
let age = stalled
.iter()
.map(|&(_, since)| now.saturating_duration_since(since))
.max()
.unwrap_or(Duration::ZERO);
metrics.set_watermark_age(age);
health.report_watermark(age, 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 = Stopwatch::start(&SystemClock);
match source.commit(&positions) {
Ok(()) => {
metrics.commit(true, started.elapsed(&SystemClock));
for &(p, o) in &positions {
state.pending_commit.remove(&p);
if checkpointer.tracks(p) {
state.committed.insert(p, o);
} else {
state.revoked_committed.insert(p, o);
}
}
}
Err(e) if is_fatal(&e) => {
metrics.commit(false, started.elapsed(&SystemClock));
state.failure.get_or_insert(FatalError {
component: "source".into(),
reason: format!("commit failed fatally: {e}"),
});
}
Err(e) if final_pass => {
metrics.commit(false, started.elapsed(&SystemClock));
tracing::warn!(error = %e, "final commit failed; uncommitted positions may replay");
}
Err(e) => {
metrics.commit(false, started.elapsed(&SystemClock));
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,
watch: StopWatch<'_>,
) {
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,
watch,
);
}
state.epoch += 1;
let mut groups: FastMap<PartitionId, Vec<S::Lane>> = FastMap::default();
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 (p, o) in std::mem::take(&mut state.revoked_committed) {
if groups.contains_key(&p) {
state.committed.entry(p).or_insert(o);
}
}
for (partition, group) in groups {
let thread = state
.thread_load
.iter()
.enumerate()
.min_by_key(|(_, load)| **load)
.map(|(i, _)| i)
.unwrap_or(0);
let gate = pending_gate(checkpointer, partition, state.epoch);
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,
gate: gate.clone(),
});
}
}
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>,
checkpointer: &mut Checkpointer,
state: &mut State,
control_txs: &[crossbeam_channel::Sender<ThreadControl<S::Lane>>],
source_metrics: &SourceMetrics,
) {
if lanes.is_empty() {
return;
}
state.revoked_committed.clear();
let mut groups: FastMap<PartitionId, Vec<S::Lane>> = FastMap::default();
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;
}
for (partition, group) in groups {
let thread = state
.thread_load
.iter()
.enumerate()
.min_by_key(|(_, load)| **load)
.map(|(i, _)| i)
.unwrap_or(0);
let gate = pending_gate(checkpointer, partition, state.epoch);
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,
gate: gate.clone(),
});
}
}
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,
watch: StopWatch<'_>,
) {
source_metrics.rebalance_revoked();
revoke_lanes(
&lanes,
barrier,
source,
checkpointer,
state,
control_txs,
checkpoint_metrics,
health,
drain_timeout,
watch,
);
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: FastMap<usize, Vec<LaneId>> = FastMap::default();
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: FastSet<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);
}
let live_partitions: FastSet<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);
for p in &to_revoke {
state.committed.remove(p);
}
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,
watch: StopWatch<'_>,
) {
let deadline = watch.clock.now() + drain_timeout;
let mut by_thread: FastMap<usize, Vec<LaneId>> = FastMap::default();
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 !wait_revocation(&barrier, deadline, source, watch) {
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,
false,
);
if let Err(e) = source.flush_commits() {
tracing::warn!(error = %e, "flush of stored commits failed during revocation");
}
let mut revoked_parts: FastSet<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);
}
let live_partitions: FastSet<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);
for p in &to_revoke {
if let Some(o) = state.committed.remove(p) {
state.revoked_committed.insert(*p, o);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_stop_reads_to_the_reserve_before_its_deadline() {
let trigger = Instant::now();
let times = StopTimes::new(trigger, Duration::from_secs(25));
assert_eq!(times.deadline, trigger + Duration::from_secs(25));
assert_eq!(times.reserve_at, trigger + Duration::from_secs(20));
let short = StopTimes::new(trigger, Duration::from_secs(3));
assert_eq!(short.reserve_at, trigger);
assert_eq!(short.deadline, trigger + Duration::from_secs(3));
}
#[test]
fn a_stop_budget_ends_by_the_deadline() {
let now = Instant::now();
let deadline = now + Duration::from_secs(4);
let fresh = Duration::from_secs(10);
assert_eq!(stop_budget(now, deadline, fresh), Duration::from_secs(4));
assert_eq!(stop_budget(now, now + fresh * 2, fresh), fresh);
assert_eq!(
stop_budget(deadline + fresh, deadline, fresh),
Duration::ZERO
);
}
#[test]
fn the_lanes_leave_the_close_its_share() {
let now = Instant::now();
let deadline = now + Duration::from_secs(10);
assert_eq!(
lanes_deadline(now, deadline, CLOSE_SHARE),
deadline - CLOSE_SHARE
);
assert_eq!(lanes_deadline(now, deadline, Duration::ZERO), deadline);
let late = deadline - Duration::from_millis(500);
assert_eq!(lanes_deadline(late, deadline, CLOSE_SHARE), late);
}
}