Skip to main content

laminar_db/pipeline/streaming_coordinator/
mod.rs

1//! Single-task pipeline coordinator on the dedicated `laminar-compute` thread.
2//!
3//! ```text
4//! Source task (main runtime) ──MAsyncTx──► StreamingCoordinator
5//!                                               │  execute_cycle / write_to_sinks
6//!                                               ▼  Sinks
7//! ```
8
9use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12
13use arrow_array::RecordBatch;
14use crossfire::{mpsc, AsyncRx};
15use laminar_connectors::checkpoint::SourceCheckpoint;
16#[cfg(test)]
17use laminar_connectors::checkpoint::SourceCheckpointDelta;
18use laminar_connectors::connector::{
19    schema_with_source_mutations_and_row_positions, schema_with_source_row_positions,
20    strip_source_row_positions, ConnectorCancellationPolicy, ConnectorTaskTracker,
21    DeliveryGuarantee, SourceBatch, SourceBatchCursor, SourceCheckpointUnavailablePolicy,
22    SourceConnector, SourceConsistency, SourceContract, SourceInputMode, SourcePosition,
23    SourceRowPositionCapability, SourceStart, SOURCE_MUTATION_COLUMN,
24};
25#[cfg(feature = "cluster")]
26use laminar_connectors::connector::{
27    SourceDrainOutcome, SourceDrainRequest, SourceDrainResolution,
28};
29use laminar_connectors::error::ConnectorError;
30#[cfg(feature = "cluster")]
31use laminar_core::checkpoint::{
32    AssignmentDrainId, AssignmentDrainTransition, CheckpointParticipant,
33};
34use laminar_core::checkpoint::{CheckpointAttempt, CheckpointAttemptRelation};
35use laminar_core::checkpoint::{CheckpointBarrier, CheckpointBarrierInjector};
36#[cfg(feature = "cluster")]
37use laminar_core::cluster::control::ClusterController;
38use rustc_hash::{FxHashMap, FxHashSet};
39
40use super::callback::{
41    BarrierOutcome, CheckpointAssignmentAdmission, CheckpointCompletion, CheckpointControlOutcome,
42    CheckpointControlWake, CycleError, CycleOutcome, PipelineCallback, PortHeadroom,
43    SourceBarrierControl, SourceBarrierSignal, SourceRegistration,
44};
45#[cfg(test)]
46use super::config::CheckpointSchedule;
47use super::config::PipelineConfig;
48use crate::catalog::{schema_has_reserved_mutation_columns, validate_source_batch};
49use crate::connector_task_fence::{ConnectorTaskFenceRegistration, OwnedConnectorTaskFences};
50use crate::error::DbError;
51
52pub(crate) use flow_metrics::CoordinatorFlowMetrics;
53use source_channel::{QueuedSourceMsg, SourceMsgRx, SourceMsgTx};
54type ControlMsgRx = AsyncRx<mpsc::Array<super::ControlMsg>>;
55type ForceCheckpointRequest = crate::db::ForceCheckpointRequest;
56
57#[derive(Clone, Copy, Debug)]
58struct CyclePublicationDurations {
59    output_store_ns: u64,
60    sink_enqueue_ns: u64,
61}
62
63mod barrier_handling;
64mod checkpoint_admission;
65mod checkpoint_lifecycle;
66mod construction;
67mod cycle;
68mod drain_ownership;
69mod execution;
70mod flow_metrics;
71mod intake_backpressure;
72use intake_backpressure::SourceAdmission;
73mod shutdown;
74mod source_actor;
75mod source_channel;
76mod source_drain;
77mod source_lifecycle;
78mod source_runtime;
79
80use source_drain::{acknowledge_latest_source_commit, take_batch_cursor, try_source_checkpoint};
81#[cfg(feature = "cluster")]
82use source_drain::{
83    apply_latest_source_drain_command_fenced, publish_source_drain_ready_fenced,
84    resolve_pending_source_drain_fenced, source_drain_flushing, source_drain_held,
85};
86#[cfg(feature = "cluster")]
87use source_lifecycle::{
88    check_source_sync_fence, source_operation_authority_error, wait_source_drain_hold,
89};
90use source_lifecycle::{
91    poll_source_once, run_source_operation, source_operation_deadline_error, start_source_once,
92    wait_source_barrier_release, wait_source_idle, SourceConnectorLifecycle,
93    SourceOperationOutcome, SourcePollOutcome, SourceStartFailure, SourceStartOutcome,
94};
95use source_runtime::{
96    send_source_msg, spawn_source_actor, wait_coordinator_delay, SourceFault, SourceMsg,
97    SourceTaskExitGuard, StreamingCoordinatorGeneration,
98};
99#[cfg(feature = "cluster")]
100use source_runtime::{
101    source_process_authority_is_live, validate_source_drain_receipts, ActiveSourceDrain,
102    PendingSourceDrainResolution, SourceDrainCommand, SourceDrainCommandPolicy,
103    SourceDrainLeaseControl, SourceDrainReceipt, SourceDrainTaskStatus, SourceProcessAuthority,
104};
105
106#[cfg(all(test, feature = "cluster"))]
107pub(crate) use drain_ownership::install_replacement_source_drain_task_for_test;
108#[cfg(feature = "cluster")]
109pub(crate) use drain_ownership::{
110    owned_source_drain_resolved, prepare_owned_source_drain, resolve_owned_source_drain,
111};
112pub use source_runtime::StreamingCoordinatorRuntime;
113pub(crate) use source_runtime::{OwnedSourceTasks, SourceTaskLease};
114
115struct SourceHandle {
116    /// Whether this source's checkpoint is a durable recovery cursor. Ephemeral source
117    /// checkpoints may align a barrier but must never enter a manifest or cluster handoff.
118    recovery_cursor: bool,
119    task: SourceTaskLease,
120    /// One-shot startup fence. Source I/O cannot begin until the compute loop has installed its
121    /// control plane and published the runtime-ready boundary.
122    startup_activation: Option<crossfire::oneshot::TxOneshot<()>>,
123    barrier_injector: CheckpointBarrierInjector,
124    /// Retained exact release/stop command for a source held after barrier emission.
125    barrier_release_tx: tokio::sync::watch::Sender<Option<SourceBarrierSignal>>,
126    /// Notifies the source of a committed `(epoch, checkpoint)` so it can ack upstream.
127    /// The checkpoint is what was written to the manifest (may lag). Empty only when the epoch
128    /// captured no state for this source; an empty one is a no-op for upstream advancement.
129    epoch_committed_tx: tokio::sync::watch::Sender<Option<(u64, SourceCheckpoint)>>,
130}
131
132pub(crate) struct TrackedSourceRegistration {
133    source: SourceRegistration,
134    contract: SourceContract,
135    expected_schema: arrow_schema::SchemaRef,
136    positioned_schema: arrow_schema::SchemaRef,
137    mutation_schema: arrow_schema::SchemaRef,
138    primary_key: Vec<String>,
139    primary_key_indices: Vec<usize>,
140    schema_admitted: bool,
141    admitted_non_append_mode: Option<SourceInputMode>,
142    task_fence: ConnectorTaskFenceRegistration,
143}
144
145pub(crate) const MUTATION_SOURCE_NOT_ADMITTED: &str =
146    "[LDB-5039] mutation sources require an exclusively admitted stateful operator route";
147
148pub(crate) fn admit_append_only_source(
149    contract: SourceContract,
150    has_reserved_mutation_columns: bool,
151) -> Result<(), &'static str> {
152    if contract.input_mode == SourceInputMode::AppendOnly && !has_reserved_mutation_columns {
153        Ok(())
154    } else {
155        Err(MUTATION_SOURCE_NOT_ADMITTED)
156    }
157}
158
159impl TrackedSourceRegistration {
160    fn metadata_schemas(
161        source_name: &str,
162        contract: SourceContract,
163        expected_schema: &arrow_schema::SchemaRef,
164    ) -> Result<(arrow_schema::SchemaRef, arrow_schema::SchemaRef), DbError> {
165        let map_error = |error| {
166            DbError::Config(format!(
167                "source '{source_name}' has an invalid source-metadata schema: {error}"
168            ))
169        };
170        let positioned = schema_with_source_row_positions(expected_schema).map_err(map_error)?;
171        let mutations =
172            schema_with_source_mutations_and_row_positions(expected_schema).map_err(map_error)?;
173        if contract.row_positions == SourceRowPositionCapability::OrderedDeterministic {
174            Ok((positioned, mutations))
175        } else {
176            Ok((Arc::clone(expected_schema), Arc::clone(expected_schema)))
177        }
178    }
179
180    fn resolve_contract(source: &SourceRegistration) -> Result<SourceContract, DbError> {
181        let contract = source.connector.contract(&source.config).map_err(|error| {
182            DbError::Config(format!(
183                "source '{}' (type '{}') has an invalid contract: {error}",
184                source.name,
185                source.config.connector_type()
186            ))
187        })?;
188        Ok(contract)
189    }
190
191    pub(crate) fn capture(
192        source: SourceRegistration,
193        owned: &OwnedConnectorTaskFences,
194    ) -> Result<Self, DbError> {
195        let contract = Self::resolve_contract(&source)?;
196        let expected_schema = source.connector.schema();
197        let (positioned_schema, mutation_schema) =
198            Self::metadata_schemas(&source.name, contract, &expected_schema)?;
199        let task_fence = ConnectorTaskFenceRegistration::capture_registered(
200            Arc::<str>::from(format!("source:{}", source.name)),
201            source.connector.terminal_task_tracker(),
202            owned,
203        );
204        Ok(Self {
205            source,
206            contract,
207            expected_schema,
208            positioned_schema,
209            mutation_schema,
210            primary_key: Vec::new(),
211            primary_key_indices: Vec::new(),
212            schema_admitted: false,
213            admitted_non_append_mode: None,
214            task_fence,
215        })
216    }
217
218    pub(crate) fn from_captured(
219        source: SourceRegistration,
220        task_fence: ConnectorTaskFenceRegistration,
221    ) -> Result<Self, DbError> {
222        let contract = Self::resolve_contract(&source)?;
223        let expected_schema = source.connector.schema();
224        let (positioned_schema, mutation_schema) =
225            Self::metadata_schemas(&source.name, contract, &expected_schema)?;
226        Ok(Self {
227            source,
228            contract,
229            expected_schema,
230            positioned_schema,
231            mutation_schema,
232            primary_key: Vec::new(),
233            primary_key_indices: Vec::new(),
234            schema_admitted: false,
235            admitted_non_append_mode: None,
236            task_fence,
237        })
238    }
239
240    pub(crate) fn with_admitted_schema(
241        mut self,
242        expected_schema: arrow_schema::SchemaRef,
243        primary_key: Vec<String>,
244    ) -> Result<Self, DbError> {
245        let primary_key_indices = primary_key
246            .iter()
247            .map(|column| {
248                expected_schema.index_of(column).map_err(|_| {
249                    DbError::Config(format!(
250                        "source '{}' primary-key column '{column}' is absent from its admitted schema",
251                        self.name
252                    ))
253                })
254            })
255            .collect::<Result<Vec<_>, _>>()?;
256        self.expected_schema = expected_schema;
257        (self.positioned_schema, self.mutation_schema) =
258            Self::metadata_schemas(&self.name, self.contract, &self.expected_schema)?;
259        self.primary_key = primary_key;
260        self.primary_key_indices = primary_key_indices;
261        self.schema_admitted = true;
262        Ok(self)
263    }
264
265    pub(crate) const fn contract(&self) -> SourceContract {
266        self.contract
267    }
268
269    /// Admit keyed mutation metadata for a temporal-right or direct keyed-mutation sink route.
270    pub(crate) fn with_keyed_upsert_mutations(mut self) -> Self {
271        debug_assert_eq!(self.contract.input_mode, SourceInputMode::KeyedUpsert);
272        self.admitted_non_append_mode = Some(SourceInputMode::KeyedUpsert);
273        self
274    }
275
276    /// Admit the trailing `__weight` of a full-changelog source read by changelog consumers.
277    pub(crate) fn with_full_changelog(mut self) -> Self {
278        debug_assert_eq!(self.contract.input_mode, SourceInputMode::FullChangelog);
279        self.admitted_non_append_mode = Some(SourceInputMode::FullChangelog);
280        self
281    }
282
283    pub(crate) fn with_ordered_interval_input_mode(
284        mut self,
285        mode: SourceInputMode,
286    ) -> Result<Self, DbError> {
287        if mode == SourceInputMode::AppendOnly || self.contract.input_mode != mode {
288            return Err(DbError::Config(format!(
289                "source '{}' lost its admitted bounded-interval input mode",
290                self.name
291            )));
292        }
293        if self
294            .admitted_non_append_mode
295            .is_some_and(|admitted| admitted != mode)
296        {
297            return Err(DbError::Config(format!(
298                "source '{}' has conflicting stateful mutation routes",
299                self.name
300            )));
301        }
302        self.admitted_non_append_mode = Some(mode);
303        Ok(self)
304    }
305
306    fn has_reserved_mutation_columns(&self) -> bool {
307        schema_has_reserved_mutation_columns(self.expected_schema.as_ref())
308    }
309}
310
311fn prepare_encoded_source_batch(
312    source_name: &str,
313    expected_schema: &arrow_schema::SchemaRef,
314    positioned_schema: &arrow_schema::SchemaRef,
315    mutation_schema: &arrow_schema::SchemaRef,
316    primary_key: &[String],
317    primary_key_indices: &[usize],
318    capability: SourceRowPositionCapability,
319    batch: SourceBatch,
320) -> Result<RecordBatch, laminar_core::streaming::StreamingError> {
321    validate_source_batch(
322        source_name,
323        expected_schema,
324        primary_key,
325        primary_key_indices,
326        &batch.records,
327    )?;
328    batch
329        .into_records_with_metadata(capability, positioned_schema, mutation_schema)
330        .map_err(|error| {
331            laminar_core::streaming::StreamingError::InvalidConfig(format!(
332                "source '{source_name}' emitted invalid source metadata: {error}"
333            ))
334        })
335}
336
337impl std::ops::Deref for TrackedSourceRegistration {
338    type Target = SourceRegistration;
339
340    fn deref(&self) -> &Self::Target {
341        &self.source
342    }
343}
344
345impl std::ops::DerefMut for TrackedSourceRegistration {
346    fn deref_mut(&mut self) -> &mut Self::Target {
347        &mut self.source
348    }
349}
350
351struct PreparedSourceGeneration {
352    registration: TrackedSourceRegistration,
353}
354
355impl SourceHandle {
356    fn barrier_control(&self) -> SourceBarrierControl {
357        SourceBarrierControl::new(
358            self.barrier_injector.clone(),
359            self.barrier_release_tx.clone(),
360        )
361    }
362}
363
364/// Why [`StreamingCoordinator::run`] returned.
365#[derive(Debug)]
366pub enum ExitReason {
367    /// Coordinator shutdown was explicitly signaled — a clean stop.
368    Shutdown,
369    /// Deterministic runtime error that recovery cannot repair. The lifecycle publishes a
370    /// terminal faulted state but must not restart this deployment automatically.
371    Halt(String),
372    /// Fatal runtime error; the lifecycle restarts or coordinates recovery as configured.
373    Fault(String),
374}
375
376struct PendingWatermarkBatch {
377    source_name: Arc<str>,
378    batch: RecordBatch,
379    admission_floor: i64,
380    input_channels: Option<Arc<[Vec<u8>]>>,
381}
382
383/// Single-task pipeline coordinator — no core threads.
384pub struct StreamingCoordinator {
385    config: PipelineConfig,
386    rx: SourceMsgRx,
387    source_fault_rx: tokio::sync::mpsc::UnboundedReceiver<SourceFault>,
388    source_handles: Vec<SourceHandle>,
389    source_names: Vec<Arc<str>>,
390    source_input_modes: Vec<Option<SourceInputMode>>,
391    shutdown: Arc<tokio::sync::Notify>,
392    terminal_shutdown: tokio_util::sync::CancellationToken,
393    pending_barrier: PendingBarrier,
394    last_checkpoint: Instant,
395    checkpoint_retry_not_before: Option<Instant>,
396    checkpoint_retry_backoff: Duration,
397    source_batches_buf: FxHashMap<Arc<str>, Vec<RecordBatch>>,
398    /// At most one FIFO message held back: removed just as the external intake gate closed, or
399    /// the first message that did not fit its source's graph ports. Later messages stay in the
400    /// FIFO behind it. Exact source barrier holds make post-barrier data impossible.
401    parked_source_msg: Option<QueuedSourceMsg>,
402    source_admission: SourceAdmission,
403    pending_watermark_batches: Vec<PendingWatermarkBatch>,
404    /// Sources that delivered a barrier this drain cycle. A later batch from one of these sources
405    /// violates the source hold protocol and faults the pipeline.
406    barrier_seen: FxHashSet<usize>,
407    /// Per-source offset advanced from `pending_offsets` after successful cycle publication.
408    committed_offsets: Vec<Option<SourceCheckpoint>>,
409    /// Cursors staged by `process_msg`; a replay-preserving deferral retains them until the graph
410    /// consumes its buffered work, while a fault discards them.
411    pending_offsets: Vec<Option<SourceBatchCursor>>,
412    /// The previous cycle retained graph work. Source intake waits for it to finish so a
413    /// newer connector cursor cannot overtake the buffered mutation.
414    replay_pending: bool,
415    control_rx: ControlMsgRx,
416    checkpoint_complete_rx:
417        Option<crossfire::AsyncRx<crossfire::mpsc::Array<CheckpointCompletion>>>,
418    /// Public checkpoint requests waiting for the next newly-admitted exact attempt.
419    force_ckpt_rx: Option<crate::db::ForceCheckpointRx>,
420    manual_waiting: Vec<ForceCheckpointRequest>,
421    /// A committed intermediate cut retained replay, so these waiters still require HANDOFF.
422    manual_handoff_required: bool,
423    /// Requests attached at admission. Later requests remain in `manual_waiting`.
424    manual_active: Option<ManualCheckpointAttempt>,
425    /// Epochs between admission and durable (tails still running); shared with callback.
426    checkpoint_in_flight: Arc<AtomicU64>,
427    /// Last durable completion published to sources/subscribers in this runtime. This is a
428    /// defense-in-depth monotonic fence in addition to serialized tail admission.
429    last_published_checkpoint: Option<CheckpointAttempt>,
430    #[cfg(feature = "cluster")]
431    process_authority: Option<Arc<SourceProcessAuthority>>,
432    public_generation: Option<StreamingCoordinatorGeneration>,
433    intake_stats: Arc<flow_metrics::IntakeStats>,
434}
435
436// These flags describe independent protocol state and cannot be combined without obscuring the
437// coordinator's transition invariants.
438#[allow(clippy::struct_excessive_bools)]
439struct CoordinatorRunState {
440    batch_window: Duration,
441    checkpoint_control_wake: Option<CheckpointControlWake>,
442    shuffle_work_wake: Option<Arc<tokio::sync::Notify>>,
443    checkpoint_control_poll_at: tokio::time::Instant,
444    checkpoint_control_pending: bool,
445    /// An observed strong intake fence restarts only the periodic checkpoint cadence when it
446    /// reopens. Manual HANDOFF requests remain immediately eligible.
447    intake_was_paused: bool,
448    barriers: Vec<(usize, CheckpointBarrier, SourceCheckpoint)>,
449    fault: Option<String>,
450    halted: bool,
451    halt_reason: Option<String>,
452    source_channel_expected: bool,
453}
454
455impl CoordinatorRunState {
456    fn halt(&mut self, reason: impl Into<String>) {
457        self.halted = true;
458        self.halt_reason.get_or_insert_with(|| reason.into());
459        self.fault = None;
460    }
461}
462
463#[derive(Default)]
464struct CoordinatorWake {
465    message: Option<QueuedSourceMsg>,
466    checkpoint_control_due: bool,
467    gates: CoordinatorGates,
468}
469
470#[derive(Default)]
471struct CoordinatorGates {
472    intake_paused: bool,
473    external_output_backpressured: bool,
474}
475
476enum CoordinatorWaitAction {
477    Cycle,
478    Continue,
479    Stop,
480}
481
482struct CoordinatorWait {
483    action: CoordinatorWaitAction,
484    wake: CoordinatorWake,
485}
486
487impl CoordinatorWait {
488    fn cycle(wake: CoordinatorWake) -> Self {
489        Self {
490            action: CoordinatorWaitAction::Cycle,
491            wake,
492        }
493    }
494
495    fn continue_loop() -> Self {
496        Self {
497            action: CoordinatorWaitAction::Continue,
498            wake: CoordinatorWake::default(),
499        }
500    }
501
502    fn stop() -> Self {
503        Self {
504            action: CoordinatorWaitAction::Stop,
505            wake: CoordinatorWake::default(),
506        }
507    }
508}
509
510impl Drop for StreamingCoordinator {
511    fn drop(&mut self) {
512        // `run` owns the coordinator by value, so cancellation or unwind would otherwise discard
513        // the only per-source shutdown controls. The DB-owned leases remain registered until the
514        // actor-exit wrapper and exact connector tracker both prove terminal completion.
515        for handle in &self.source_handles {
516            handle.task.request_shutdown();
517        }
518        self.intake_stats.close();
519        // Release public construction ownership only after every source has observed shutdown.
520        drop(self.public_generation.take());
521    }
522}
523
524/// Public checkpoint callers attached to one exact attempt at admission time.
525struct ManualCheckpointAttempt {
526    attempt: CheckpointAttempt,
527    flags: u64,
528    requests: Vec<ForceCheckpointRequest>,
529}
530
531struct CheckpointAdmission {
532    manual: bool,
533    flags: u64,
534    assignment_fence: Option<laminar_core::cluster::control::CheckpointAssignmentFence>,
535    /// Exact assignment/drain serialization claim returned by the callback. Clustered sourced
536    /// attempts retain it through Prepare and source-barrier installation; source-less attempts
537    /// release it after Prepare so mutable capture may acquire the graph's fair rotation fence.
538    assignment_guard: Option<tokio::sync::OwnedMutexGuard<()>>,
539    deadline: tokio::time::Instant,
540}
541
542#[derive(Clone, Copy, Debug, PartialEq, Eq)]
543enum CheckpointCleanupOwner {
544    /// Embedded, single-node, or cluster-leader attempt originator.
545    Originator,
546    /// Cluster follower that reserved an attempt announced by the originator.
547    Follower,
548}
549
550struct AlignedCheckpointContext {
551    cleanup_owner: CheckpointCleanupOwner,
552    attempt: CheckpointAttempt,
553    started_at: Instant,
554    flags: u64,
555    assignment_fence: Option<laminar_core::cluster::control::CheckpointAssignmentFence>,
556}
557
558/// Tracks in-flight checkpoint barrier alignment.
559struct PendingBarrier {
560    attempt: Option<CheckpointAttempt>,
561    sources_total: usize,
562    sources_aligned: FxHashSet<usize>,
563    source_checkpoints: FxHashMap<String, SourceCheckpoint>,
564    started_at: Instant,
565    deadline: Option<tokio::time::Instant>,
566    active: bool,
567    flags: u64,
568    assignment_fence: Option<laminar_core::cluster::control::CheckpointAssignmentFence>,
569    cleanup_owner: CheckpointCleanupOwner,
570}
571
572impl PendingBarrier {
573    fn new() -> Self {
574        Self {
575            attempt: None,
576            sources_total: 0,
577            sources_aligned: FxHashSet::default(),
578            source_checkpoints: FxHashMap::default(),
579            started_at: Instant::now(),
580            deadline: None,
581            active: false,
582            flags: laminar_core::checkpoint::flags::NONE,
583            assignment_fence: None,
584            cleanup_owner: CheckpointCleanupOwner::Originator,
585        }
586    }
587
588    #[cfg(test)]
589    fn reset(&mut self, attempt: CheckpointAttempt, sources_total: usize) {
590        self.reset_with_assignment(
591            attempt,
592            sources_total,
593            laminar_core::checkpoint::flags::NONE,
594            None,
595            None,
596        );
597    }
598
599    fn reset_follower(&mut self, attempt: CheckpointAttempt, sources_total: usize, flags: u64) {
600        self.reset_inner(
601            attempt,
602            sources_total,
603            flags,
604            None,
605            CheckpointCleanupOwner::Follower,
606        );
607    }
608
609    fn reset_with_assignment(
610        &mut self,
611        attempt: CheckpointAttempt,
612        sources_total: usize,
613        flags: u64,
614        assignment_fence: Option<laminar_core::cluster::control::CheckpointAssignmentFence>,
615        deadline: Option<tokio::time::Instant>,
616    ) {
617        self.reset_inner(
618            attempt,
619            sources_total,
620            flags,
621            assignment_fence,
622            CheckpointCleanupOwner::Originator,
623        );
624        self.deadline = deadline;
625    }
626
627    fn attempt_deadline(&self, checkpoint_timeout: Duration) -> tokio::time::Instant {
628        self.deadline
629            .unwrap_or_else(|| tokio::time::Instant::from_std(self.started_at) + checkpoint_timeout)
630    }
631
632    fn reset_inner(
633        &mut self,
634        attempt: CheckpointAttempt,
635        sources_total: usize,
636        flags: u64,
637        assignment_fence: Option<laminar_core::cluster::control::CheckpointAssignmentFence>,
638        cleanup_owner: CheckpointCleanupOwner,
639    ) {
640        self.attempt = Some(attempt);
641        self.sources_total = sources_total;
642        self.sources_aligned.clear();
643        self.source_checkpoints.clear();
644        self.started_at = Instant::now();
645        self.deadline = None;
646        self.active = true;
647        self.flags = flags;
648        self.assignment_fence = assignment_fence;
649        self.cleanup_owner = cleanup_owner;
650    }
651
652    /// Clear alignment state and return the exact active attempt and its cleanup owner.
653    fn take_active_attempt(&mut self) -> Option<(CheckpointAttempt, CheckpointCleanupOwner)> {
654        if !self.active {
655            return None;
656        }
657        let cleanup_owner = self.cleanup_owner;
658        self.active = false;
659        self.sources_total = 0;
660        self.sources_aligned.clear();
661        self.source_checkpoints.clear();
662        self.flags = laminar_core::checkpoint::flags::NONE;
663        self.assignment_fence = None;
664        self.deadline = None;
665        self.cleanup_owner = CheckpointCleanupOwner::Originator;
666        self.attempt.take().map(|attempt| (attempt, cleanup_owner))
667    }
668
669    fn clear(&mut self) {
670        self.active = false;
671        self.attempt = None;
672        self.sources_total = 0;
673        self.sources_aligned.clear();
674        self.source_checkpoints.clear();
675        self.flags = laminar_core::checkpoint::flags::NONE;
676        self.assignment_fence = None;
677        self.deadline = None;
678        self.cleanup_owner = CheckpointCleanupOwner::Originator;
679    }
680}
681
682/// Fallback timeout for idle wake.
683pub(crate) const IDLE_TIMEOUT: Duration = Duration::from_millis(100);
684
685/// Internal topology-retry floor and cap. Assignment admission remains the authoritative gate.
686const CHECKPOINT_RETRY_BASE: Duration = Duration::from_millis(100);
687const CHECKPOINT_RETRY_MAX: Duration = Duration::from_secs(5);
688
689/// Cap on a source task's post-shutdown flush so a hot source can't stall shutdown.
690const SHUTDOWN_DRAIN_BUDGET: Duration = Duration::from_secs(2);
691
692/// Cap on awaiting a source task at shutdown before retiring its connector generation.
693const SHUTDOWN_JOIN_TIMEOUT: Duration = Duration::from_secs(8);
694
695/// Shutdown-only poll cadence. It closes the atomic/channel race when a tail drops its in-flight
696/// guard without producing another wakeup (for example a cluster follower tail with no
697/// completion).
698const SHUTDOWN_COMPLETION_TICK: Duration = Duration::from_millis(10);
699
700#[cfg(test)]
701mod tests;