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