Skip to main content

laminar_db/pipeline/streaming_coordinator/
execution.rs

1//! Mechanically extracted coordinator responsibility.
2
3use super::{
4    wait_coordinator_delay, CheckpointCompletion, CheckpointControlWake, CoordinatorRunState,
5    CoordinatorWait, CoordinatorWaitAction, CoordinatorWake, CycleError, ExitReason,
6    ForceCheckpointRequest, Instant, PipelineCallback, SourceHandle, StreamingCoordinator,
7    IDLE_TIMEOUT,
8};
9
10impl StreamingCoordinator {
11    /// Run the coordinator loop until shutdown or a fatal cycle fault.
12    ///
13    /// Cycle priority: (1) shutdown, (2) drain + SQL, (3) barrier alignment,
14    /// (4) checkpointing, (5) control, (6) barrier timeout.
15    pub async fn run<C: PipelineCallback>(self, callback: C) -> ExitReason {
16        self.run_inner(callback, None).await
17    }
18
19    /// Run the coordinator and report when its control loop is ready.
20    ///
21    /// Pipeline startup and coordinated recovery use this stronger boundary: constructing the
22    /// compute runtime is not enough, because recovery must not acknowledge a node before barrier
23    /// injection and manual-checkpoint control are installed on the live loop.
24    pub(crate) async fn run_with_ready<C: PipelineCallback>(
25        self,
26        callback: C,
27        ready: crossfire::oneshot::TxOneshot<Result<(), String>>,
28    ) -> ExitReason {
29        self.run_inner(callback, Some(ready)).await
30    }
31
32    pub(super) async fn wait_for_cycle<C: PipelineCallback>(
33        &mut self,
34        callback: &mut C,
35        state: &mut CoordinatorRunState,
36    ) -> CoordinatorWait {
37        if let Err(error) = callback.prepare_source_intake() {
38            state.fault = Some(format!(
39                "source recovery handoff could not be installed before intake: {error}"
40            ));
41            return CoordinatorWait::stop();
42        }
43        let gates = self.capture_gates(callback);
44        let replay_ready = self.replay_pending
45            && gates.compute_admitted()
46            && (self.manual_handoff_required || callback.has_runnable_deferred_input());
47        let intake_open = !self.replay_pending && gates.compute_admitted();
48        let parked_ready = intake_open && self.parked_message_ready(&*callback);
49        let fifo_open =
50            intake_open && state.source_channel_expected && self.parked_source_msg.is_none();
51        let mut checkpoint_control_due = false;
52
53        let message = tokio::select! {
54            biased;
55            () = self.terminal_shutdown.cancelled() => return CoordinatorWait::stop(),
56            () = self.shutdown.notified() => return CoordinatorWait::stop(),
57            Some(source_fault) = self.source_fault_rx.recv() => {
58                state.fault = Some(format!(
59                    "source '{}' fault: {}",
60                    source_fault.source, source_fault.error
61                ));
62                return CoordinatorWait::stop();
63            }
64            Some(completion) = async {
65                if let Some(ref mut rx) = self.checkpoint_complete_rx {
66                    rx.recv().await.ok()
67                } else {
68                    futures::future::pending::<Option<CheckpointCompletion>>().await
69                }
70            } => {
71                if let Some(error) = self.handle_checkpoint_completion(completion, callback) {
72                    state.fault = Some(error);
73                    return CoordinatorWait::stop();
74                }
75                if !state.checkpoint_control_pending {
76                    return CoordinatorWait::continue_loop();
77                }
78                checkpoint_control_due = true;
79                if state.checkpoint_control_wake.is_some() {
80                    state.checkpoint_control_poll_at =
81                        tokio::time::Instant::now() + CheckpointControlWake::capacity_retry();
82                }
83                None
84            }
85            Some(reply) = async {
86                if let Some(ref mut rx) = self.force_ckpt_rx {
87                    rx.recv().await.ok()
88                } else {
89                    futures::future::pending::<Option<ForceCheckpointRequest>>().await
90                }
91            } => {
92                self.manual_waiting.push(reply);
93                None
94            },
95            () = async {
96                match state.checkpoint_control_wake.as_mut() {
97                    Some(wake) => wake.wait_until(state.checkpoint_control_poll_at).await,
98                    None => std::future::pending().await,
99                }
100            }, if !state.checkpoint_control_pending && !callback.is_leader() => {
101                state.checkpoint_control_pending = true;
102                checkpoint_control_due = true;
103                if state.checkpoint_control_wake.is_some() {
104                    state.checkpoint_control_poll_at =
105                        tokio::time::Instant::now() + CheckpointControlWake::capacity_retry();
106                }
107                None
108            },
109            () = tokio::time::sleep_until(state.checkpoint_control_poll_at),
110                if state.checkpoint_control_pending && !callback.is_leader() =>
111            {
112                checkpoint_control_due = true;
113                if state.checkpoint_control_wake.is_some() {
114                    state.checkpoint_control_poll_at =
115                        tokio::time::Instant::now() + CheckpointControlWake::capacity_retry();
116                }
117                None
118            },
119            () = std::future::ready(()), if replay_ready => None,
120            () = std::future::ready(()), if parked_ready => self.parked_source_msg.take(),
121            msg = self.rx.recv(), if fifo_open => {
122                if let Ok(message) = msg {
123                    if !state.batch_window.is_zero() {
124                        let authority_lost = wait_coordinator_delay(
125                            state.batch_window,
126                            #[cfg(feature = "cluster")]
127                            self.process_authority.as_deref(),
128                        )
129                        .await;
130                        if authority_lost {
131                            state.fault = Some(
132                                "cluster process lease expired during source batch window".into(),
133                            );
134                            return CoordinatorWait::stop();
135                        }
136                    }
137                    Some(message)
138                } else {
139                    state.fault = Some("all configured source tasks exited unexpectedly".into());
140                    return CoordinatorWait::stop();
141                }
142            }
143            () = async {
144                match state.shuffle_work_wake.as_ref() {
145                    Some(wake) => wake.notified().await,
146                    None => std::future::pending().await,
147                }
148            }, if !gates.external_output_backpressured => None,
149            authority_lost = wait_coordinator_delay(
150                IDLE_TIMEOUT,
151                #[cfg(feature = "cluster")]
152                self.process_authority.as_deref(),
153            ) => {
154                if authority_lost {
155                    state.fault =
156                        Some("cluster process lease expired while coordinator was idle".into());
157                    return CoordinatorWait::stop();
158                }
159                None
160            },
161        };
162
163        CoordinatorWait::cycle(CoordinatorWake {
164            message,
165            checkpoint_control_due,
166            gates,
167        })
168    }
169
170    pub(super) async fn service_background_work<C: PipelineCallback>(
171        &mut self,
172        callback: &mut C,
173        state: &mut CoordinatorRunState,
174        checkpoint_control_due: bool,
175    ) -> bool {
176        if self.replay_pending && self.pending_barrier.active {
177            let deadline = self
178                .pending_barrier
179                .attempt_deadline(self.config.checkpoint_timeout);
180            if let Err(error) = callback.drain_checkpoint_edges_until(deadline).await {
181                let reason = error.to_string();
182                let terminal = matches!(&error, CycleError::Halt(_));
183                if terminal {
184                    state.halt(reason.clone());
185                }
186                tracing::error!(%error, "checkpoint replay drain failed");
187                if let Err(cleanup) = self
188                    .cancel_pending_barrier_for_stop(callback, &reason, true)
189                    .await
190                {
191                    if terminal {
192                        tracing::warn!(
193                            %cleanup,
194                            "checkpoint cleanup also failed after a permanent replay-drain halt"
195                        );
196                    } else {
197                        state.fault = Some(format!(
198                            "{reason}; checkpoint cleanup failed after replay drain: {cleanup}"
199                        ));
200                    }
201                    return false;
202                }
203                match error {
204                    CycleError::Halt(_) => {}
205                    CycleError::Fatal(_) | CycleError::Recovery(_) => state.fault = Some(reason),
206                }
207                return false;
208            }
209            if let Err(error) = self.commit_pending_offsets() {
210                state.fault = Some(error.to_string());
211                return false;
212            }
213            self.replay_pending = false;
214        }
215
216        for (source_idx, barrier, checkpoint) in &state.barriers {
217            match self
218                .handle_barrier(*source_idx, barrier, checkpoint, callback)
219                .await
220            {
221                Ok(()) => {}
222                Err(CycleError::Halt(reason)) => {
223                    tracing::warn!(%reason, "[LDB-3022] checkpoint drain halted the pipeline");
224                    state.halt(reason);
225                    return false;
226                }
227                Err(CycleError::Fatal(reason) | CycleError::Recovery(reason)) => {
228                    state.fault = Some(reason);
229                    return false;
230                }
231            }
232        }
233        if self.terminal_shutdown.is_cancelled() {
234            return false;
235        }
236        if let Some(reason) = callback.take_pipeline_halt() {
237            self.discard_pending_offsets();
238            tracing::warn!(
239                reason = %reason,
240                "[LDB-3022] permanent pipeline error; stopping without recovery"
241            );
242            state.halt(reason);
243            return false;
244        }
245        if let Some(reason) = callback.take_pipeline_fault() {
246            self.discard_pending_offsets();
247            tracing::error!(
248                reason = %reason,
249                "[LDB-3024] pipeline consistency fault; stopping for recovery"
250            );
251            state.fault = Some(reason);
252            return false;
253        }
254
255        let follower_control_ready =
256            state.checkpoint_control_pending && checkpoint_control_due && !callback.is_leader();
257        let checkpoint_work_due =
258            callback.is_leader() || follower_control_ready || !self.manual_waiting.is_empty();
259        if checkpoint_work_due {
260            // Admission can already have reserved an exact attempt and begun its durable artifact
261            // inventory before Prepare publication blocks on external control-plane I/O. A
262            // recovery stop must still reach `finish_run`: already-spawned tails are settled
263            // there, while an interrupted pre-tail attempt remains visible for coordinated
264            // recovery's authoritative artifact reconciliation. Do not synthesize a local
265            // completion for that ambiguous publication.
266            let terminal_shutdown = self.terminal_shutdown.clone();
267            let control_serviced = tokio::select! {
268                biased;
269                () = terminal_shutdown.cancelled() => return false,
270                serviced = self.maybe_checkpoint(callback) => serviced,
271            };
272            if follower_control_ready {
273                #[cfg(feature = "cluster")]
274                if let Some(wake) = state.checkpoint_control_wake.as_ref() {
275                    if control_serviced {
276                        state.checkpoint_control_pending = false;
277                        state.checkpoint_control_poll_at =
278                            tokio::time::Instant::now() + wake.fallback();
279                    } else {
280                        state.checkpoint_control_poll_at =
281                            tokio::time::Instant::now() + CheckpointControlWake::capacity_retry();
282                    }
283                }
284                #[cfg(not(feature = "cluster"))]
285                if state.checkpoint_control_wake.is_some() {
286                    state.checkpoint_control_poll_at = tokio::time::Instant::now()
287                        + if control_serviced {
288                            CheckpointControlWake::fallback()
289                        } else {
290                            CheckpointControlWake::capacity_retry()
291                        };
292                    state.checkpoint_control_pending &= !control_serviced;
293                }
294            }
295            if let Some(reason) = callback.take_pipeline_halt() {
296                self.discard_pending_offsets();
297                tracing::warn!(
298                    reason = %reason,
299                    "[LDB-3022] checkpoint control observed a permanent pipeline error"
300                );
301                state.halt(reason);
302                return false;
303            }
304            if let Some(reason) = callback.take_pipeline_fault() {
305                self.discard_pending_offsets();
306                tracing::error!(
307                    reason = %reason,
308                    "[LDB-3024] checkpoint control fault; stopping for recovery"
309                );
310                state.fault = Some(reason);
311                return false;
312            }
313        }
314
315        while let Ok(message) = self.control_rx.try_recv() {
316            #[cfg(feature = "cluster")]
317            if let Err(error) = self.require_process_authority("pipeline control mutation") {
318                state.fault = Some(error.to_string());
319                return false;
320            }
321            callback.apply_control(message);
322        }
323
324        if self.pending_barrier.active
325            && tokio::time::Instant::now()
326                >= self
327                    .pending_barrier
328                    .attempt_deadline(self.config.checkpoint_timeout)
329        {
330            if let Err(error) = self
331                .cancel_pending_barrier_for_stop(callback, "source barrier alignment timeout", true)
332                .await
333            {
334                state.fault = Some(error);
335                return false;
336            }
337        }
338        true
339    }
340
341    pub(super) async fn run_inner<C: PipelineCallback>(
342        mut self,
343        mut callback: C,
344        ready: Option<crossfire::oneshot::TxOneshot<Result<(), String>>>,
345    ) -> ExitReason {
346        let injectors = self
347            .source_handles
348            .iter()
349            .map(SourceHandle::barrier_control)
350            .collect();
351        callback.set_barrier_injectors(injectors);
352        let cancelled_before_ready = self.terminal_shutdown.is_cancelled();
353        if let Some(ready) = ready {
354            if cancelled_before_ready {
355                ready.send(Err(
356                    "pipeline runtime generation was cancelled before readiness".into(),
357                ));
358            } else {
359                ready.send(Ok(()));
360            }
361        }
362        if !cancelled_before_ready && !self.terminal_shutdown.is_cancelled() {
363            // Readiness is the linearization point: source tasks are released only after it is
364            // published, so none can enqueue a batch, barrier, or fault on the pre-ready side.
365            for handle in &mut self.source_handles {
366                if let Some(activation) = handle.startup_activation.take() {
367                    activation.send(());
368                }
369            }
370        }
371
372        let intake_was_paused = callback.intake_paused();
373        let mut state = CoordinatorRunState {
374            batch_window: self.config.batch_window,
375            checkpoint_control_wake: callback.checkpoint_control_wake(),
376            shuffle_work_wake: callback.shuffle_work_wake(),
377            checkpoint_control_poll_at: tokio::time::Instant::now(),
378            checkpoint_control_pending: false,
379            intake_was_paused,
380            barriers: Vec::new(),
381            fault: None,
382            halted: false,
383            halt_reason: None,
384            source_channel_expected: !self.source_names.is_empty(),
385        };
386
387        loop {
388            if self.terminal_shutdown.is_cancelled() {
389                break;
390            }
391            let wait = self.wait_for_cycle(&mut callback, &mut state).await;
392            match wait.action {
393                CoordinatorWaitAction::Cycle => {}
394                CoordinatorWaitAction::Continue => continue,
395                CoordinatorWaitAction::Stop => break,
396            }
397            let CoordinatorWake {
398                message: msg,
399                checkpoint_control_due,
400                gates,
401            } = wait.wake;
402            // Recheck after the await: recovery may have closed the gate after this loop removed
403            // a message from the source FIFO. Keep that message ahead of later FIFO entries so a
404            // transient close/reopen cannot silently lose it. A fenced shutdown still discards
405            // all open-epoch data below, where recovery owns the rewind.
406            let intake_blocked = gates.intake_paused || callback.intake_paused();
407            self.observe_intake_gates(&mut state, &gates, intake_blocked);
408            if intake_blocked {
409                if !self
410                    .service_paused_intake(&mut callback, &mut state, msg)
411                    .await
412                {
413                    break;
414                }
415                continue;
416            }
417            if gates.external_output_backpressured {
418                if !self
419                    .service_external_output_backpressure(
420                        &mut callback,
421                        &mut state,
422                        checkpoint_control_due,
423                    )
424                    .await
425                {
426                    break;
427                }
428                continue;
429            }
430
431            if !self.replay_pending {
432                if let Err(error) = callback.pin_source_frontiers_for_new_cycle() {
433                    state.fault = Some(format!(
434                        "decision-bound source frontiers could not be pinned before intake: {error}"
435                    ));
436                    break;
437                }
438            }
439
440            self.begin_source_staging(&callback);
441            self.reset_barrier_seen_for_cycle();
442            if !self.replay_pending {
443                self.discard_pending_offsets();
444            }
445            state.barriers.clear();
446            let mut cycle_events: u64 = 0;
447            let cycle_start = Instant::now();
448
449            if let Err(error) = self.stage_source_input(
450                msg,
451                &mut callback,
452                &mut state.barriers,
453                &mut cycle_events,
454                cycle_start,
455            ) {
456                match error {
457                    CycleError::Halt(reason) => {
458                        tracing::warn!(%reason, "[LDB-3022] source staging halted");
459                        state.halt(reason);
460                    }
461                    CycleError::Fatal(reason) | CycleError::Recovery(reason) => {
462                        state.fault = Some(reason);
463                    }
464                }
465            }
466            if let Ok(source_fault) = self.source_fault_rx.try_recv() {
467                state.fault = Some(format!(
468                    "source '{}' fault: {}",
469                    source_fault.source, source_fault.error
470                ));
471            }
472            if state.halted || state.fault.is_some() {
473                self.discard_pending_offsets();
474                break;
475            }
476            #[cfg(feature = "cluster")]
477            if let Err(error) = self.require_process_authority("folding a drained source cycle") {
478                self.discard_pending_offsets();
479                state.fault = Some(error.to_string());
480                break;
481            }
482
483            let staged_source_progress = !self.pending_watermark_batches.is_empty();
484            let watermark_result =
485                self.pending_watermark_batches
486                    .drain(..)
487                    .try_for_each(|pending| {
488                        callback.reconcile_source_input_channels(
489                            &pending.source_name,
490                            pending.input_channels,
491                        )?;
492                        callback.extract_watermark(
493                            &pending.source_name,
494                            &pending.batch,
495                            pending.admission_floor,
496                        )
497                    });
498            if let Err(error) = watermark_result {
499                self.discard_pending_offsets();
500                match error {
501                    CycleError::Halt(reason) => {
502                        tracing::warn!(%reason, "[LDB-3022] watermark processing halted");
503                        state.halt(reason);
504                    }
505                    CycleError::Fatal(reason) | CycleError::Recovery(reason) => {
506                        state.fault = Some(reason);
507                    }
508                }
509                break;
510            }
511
512            if !self.replay_pending {
513                callback.tick_idle_watermark();
514            }
515
516            // Run empty cycles for filtered source progress and deferred operator work so cursors,
517            // watermarks, and retained data do not stall when a source goes quiet.
518            if !self.source_batches_buf.is_empty()
519                || staged_source_progress
520                || self.replay_pending
521                || callback.has_deferred_input()
522            {
523                let wm = callback.current_watermark();
524                #[cfg(feature = "cluster")]
525                if let Err(error) = self.require_process_authority("operator execution") {
526                    self.discard_pending_offsets();
527                    state.fault = Some(error.to_string());
528                    break;
529                }
530                let execute_started = Instant::now();
531                let execute_result = callback.execute_cycle(&self.source_batches_buf, wm).await;
532                self.release_staged_permits();
533                let execute_ns =
534                    u64::try_from(execute_started.elapsed().as_nanos()).unwrap_or(u64::MAX);
535                match execute_result {
536                    Ok(out) => {
537                        // Durable delivery rewinds the whole pipeline, so don't partial-commit
538                        // siblings — recover instead.
539                        if out.any_failed && callback.fault_on_cycle_error() {
540                            self.discard_pending_offsets();
541                            tracing::error!(
542                                "[LDB-3021] failure domain faulted; faulting for recovery"
543                            );
544                            state.fault =
545                                Some("isolated domain fault (durable delivery)".to_string());
546                            break;
547                        }
548                        let publication =
549                            match self.publish_cycle_outputs(&mut callback, &out).await {
550                                Ok(publication) => publication,
551                                Err(CycleError::Halt(reason)) => {
552                                    tracing::warn!(
553                                        %reason,
554                                        "[LDB-3022] cycle output publication halted"
555                                    );
556                                    state.halt(reason);
557                                    break;
558                                }
559                                Err(CycleError::Recovery(reason) | CycleError::Fatal(reason)) => {
560                                    tracing::error!(
561                                        error = %reason,
562                                        "cycle output publication failed; faulting for recovery"
563                                    );
564                                    state.fault = Some(reason);
565                                    break;
566                                }
567                            };
568                        callback.record_cycle_phases(
569                            execute_ns,
570                            publication.output_store_ns,
571                            publication.sink_enqueue_ns,
572                        );
573                        if out.any_failed {
574                            callback.note_cycle_error();
575                            tracing::warn!(
576                                "[LDB-3020] failure domain dropped (best-effort: continuing)"
577                            );
578                        }
579                    }
580                    Err(e) => {
581                        self.discard_pending_offsets();
582                        match e {
583                            CycleError::Recovery(msg) => {
584                                tracing::error!(
585                                    error = %msg,
586                                    "shared pipeline infrastructure failed; faulting for recovery"
587                                );
588                                state.fault = Some(msg);
589                                break;
590                            }
591                            // Shutdown already signaled; restarting would just re-trip it.
592                            CycleError::Halt(msg) => {
593                                tracing::warn!(reason = %msg, "[LDB-3022] cycle halted");
594                                state.halt(msg);
595                                break;
596                            }
597                            // Continuing would drop drained rows, so durable delivery faults for
598                            // recovery.
599                            CycleError::Fatal(msg) if callback.fault_on_cycle_error() => {
600                                tracing::error!(
601                                    error = %msg,
602                                    "[LDB-3021] fatal SQL cycle error; faulting for recovery"
603                                );
604                                state.fault = Some(msg);
605                                break;
606                            }
607                            // Best effort: drop the bad cycle and continue.
608                            CycleError::Fatal(msg) => {
609                                callback.note_cycle_error();
610                                tracing::warn!(
611                                    error = %msg,
612                                    "[LDB-3020] SQL cycle error (best-effort: continuing)"
613                                );
614                            }
615                        }
616                    }
617                }
618                let elapsed_ns =
619                    u64::try_from(cycle_start.elapsed().as_nanos()).unwrap_or(u64::MAX);
620                callback.record_cycle(cycle_events, 0, elapsed_ns);
621
622                if elapsed_ns >= self.config.cycle_budget_ns {
623                    tracing::debug!(
624                        elapsed_ms = elapsed_ns / 1_000_000,
625                        budget_ms = self.config.cycle_budget_ns / 1_000_000,
626                        "cycle budget exceeded — proceeding to background work"
627                    );
628                }
629            }
630
631            if !self
632                .service_background_work(&mut callback, &mut state, checkpoint_control_due)
633                .await
634            {
635                break;
636            }
637        }
638
639        self.finish_run(&mut callback, state.fault, state.halted, state.halt_reason)
640            .await
641    }
642}