Skip to main content

datum_agent/
registry.rs

1use std::{
2    collections::HashMap,
3    future::Future,
4    panic::{AssertUnwindSafe, catch_unwind},
5    sync::{
6        OnceLock,
7        atomic::{AtomicU64, Ordering},
8        mpsc,
9    },
10    time::{Duration, Instant, SystemTime},
11};
12
13use datum::{
14    Materializer, NotUsed, RunnableGraph, Source, StreamError, StreamInstrumentationRegistry,
15    StreamResult, Topic, TopicOverflow, TopicTryPublishError,
16};
17use ractor::{Actor, ActorProcessingErr, ActorRef};
18use tokio::runtime::{Builder as TokioRuntimeBuilder, Runtime as TokioRuntime};
19
20use crate::{
21    AgentConfig, AgentError, AgentResult, DesiredJobState, JobContext, JobControl,
22    JobDrainBehavior, JobEvent, JobEventKind, JobExitReason, JobId, JobMat, JobSpec, JobState,
23    JobStatus,
24    job::{RestartCause, new_generation_kill_switch},
25};
26
27type Reply<T> = mpsc::Sender<AgentResult<T>>;
28
29/// Ractor-backed local job registry.
30pub struct JobRegistry;
31
32impl JobRegistry {
33    pub fn start(config: AgentConfig) -> AgentResult<JobRegistryHandle> {
34        let event_buffer = config.event_buffer.max(1);
35        let events = Topic::new(event_buffer, TopicOverflow::Sliding)?;
36        let args = RegistryArgs {
37            instrumentation: config.instrumentation.clone(),
38            config,
39            events: events.clone(),
40            materializer: Materializer::new(),
41        };
42        let (actor, _join) =
43            block_on_agent_runtime(RegistryActor::spawn(None, RegistryActor, args)).and_then(
44                |result| {
45                    result.map_err(|error| {
46                        AgentError::Actor(format!("job registry failed to spawn: {error}"))
47                    })
48                },
49            )?;
50        Ok(JobRegistryHandle { actor, events })
51    }
52}
53
54/// Cloneable in-process handle to the registry actor.
55#[derive(Clone)]
56pub struct JobRegistryHandle {
57    actor: ActorRef<RegistryMessage>,
58    events: Topic<JobEvent>,
59}
60
61impl JobRegistryHandle {
62    pub fn submit(&self, spec: JobSpec) -> AgentResult<JobStatus> {
63        self.request(|reply| RegistryMessage::Submit {
64            spec: Box::new(spec),
65            reply,
66        })
67    }
68
69    pub fn start(&self, name: impl Into<String>) -> AgentResult<JobStatus> {
70        let name = name.into();
71        self.request(|reply| RegistryMessage::Start { name, reply })
72    }
73
74    pub fn restart(&self, name: impl Into<String>) -> AgentResult<JobStatus> {
75        let name = name.into();
76        self.request(|reply| RegistryMessage::Restart { name, reply })
77    }
78
79    pub fn drain(&self, name: impl Into<String>) -> AgentResult<JobStatus> {
80        let name = name.into();
81        self.request(|reply| RegistryMessage::Drain { name, reply })
82    }
83
84    pub fn stop(&self, name: impl Into<String>) -> AgentResult<JobStatus> {
85        let name = name.into();
86        self.request(|reply| RegistryMessage::Stop { name, reply })
87    }
88
89    pub fn status(&self, name: impl Into<String>) -> AgentResult<JobStatus> {
90        let name = name.into();
91        self.request(|reply| RegistryMessage::Status { name, reply })
92    }
93
94    pub fn list(&self) -> AgentResult<Vec<JobStatus>> {
95        self.request(|reply| RegistryMessage::List { reply })
96    }
97
98    #[must_use]
99    pub fn events(&self) -> Source<JobEvent> {
100        self.events.subscribe()
101    }
102
103    pub fn shutdown(&self) -> AgentResult<()> {
104        self.request(RegistryMessage::Shutdown)
105    }
106
107    fn request<T, F>(&self, make: F) -> AgentResult<T>
108    where
109        T: Send + 'static,
110        F: FnOnce(Reply<T>) -> RegistryMessage,
111    {
112        let (reply, receiver) = mpsc::channel();
113        self.actor
114            .send_message(make(reply))
115            .map_err(|_| AgentError::RegistryClosed)?;
116        receiver.recv().map_err(|_| AgentError::RegistryClosed)?
117    }
118}
119
120struct RegistryArgs {
121    instrumentation: StreamInstrumentationRegistry,
122    config: AgentConfig,
123    events: Topic<JobEvent>,
124    materializer: Materializer,
125}
126
127struct RegistryActor;
128
129impl Actor for RegistryActor {
130    type Msg = RegistryMessage;
131    type State = RegistryState;
132    type Arguments = RegistryArgs;
133
134    async fn pre_start(
135        &self,
136        _myself: ActorRef<Self::Msg>,
137        args: Self::Arguments,
138    ) -> Result<Self::State, ActorProcessingErr> {
139        Ok(RegistryState {
140            config: args.config,
141            materializer: args.materializer,
142            events: args.events,
143            instrumentation: args.instrumentation,
144            next_job_id: 1,
145            next_event_sequence: 1,
146            jobs: HashMap::new(),
147        })
148    }
149
150    async fn post_start(
151        &self,
152        myself: ActorRef<Self::Msg>,
153        state: &mut Self::State,
154    ) -> Result<(), ActorProcessingErr> {
155        let interval = state.config.poll_interval.max(Duration::from_millis(1));
156        spawn_completion_poller(myself, interval);
157        Ok(())
158    }
159
160    async fn post_stop(
161        &self,
162        _myself: ActorRef<Self::Msg>,
163        state: &mut Self::State,
164    ) -> Result<(), ActorProcessingErr> {
165        abort_all_jobs(state);
166        let materializer = state.materializer.clone();
167        let events = state.events.clone();
168        materializer.shutdown();
169        let _ = events.close();
170        Ok(())
171    }
172
173    async fn handle(
174        &self,
175        myself: ActorRef<Self::Msg>,
176        message: Self::Msg,
177        state: &mut Self::State,
178    ) -> Result<(), ActorProcessingErr> {
179        match message {
180            RegistryMessage::Submit { spec, reply } => {
181                send_reply(reply, submit_job(state, *spec));
182            }
183            RegistryMessage::Start { name, reply } => {
184                send_reply(reply, start_job(state, &myself, &name, StartKind::Initial));
185            }
186            RegistryMessage::Restart { name, reply } => {
187                send_reply(reply, restart_job(state, &myself, &name));
188            }
189            RegistryMessage::Drain { name, reply } => {
190                send_reply(reply, drain_job(state, &name));
191            }
192            RegistryMessage::Stop { name, reply } => {
193                send_reply(reply, stop_job(state, &name, JobExitReason::Stopped));
194            }
195            RegistryMessage::Status { name, reply } => {
196                send_reply(reply, status_by_name(state, &name));
197            }
198            RegistryMessage::List { reply } => {
199                send_reply(reply, Ok(list_statuses(state)));
200            }
201            RegistryMessage::Tick => {
202                poll_jobs(state, &myself);
203            }
204            RegistryMessage::RestartReady {
205                name,
206                expected_generation,
207            } => {
208                restart_ready(state, &myself, &name, expected_generation);
209            }
210            RegistryMessage::Shutdown(reply) => {
211                abort_all_jobs(state);
212                let _ = reply.send(Ok(()));
213                myself.stop(Some("datum-agent shutdown".to_owned()));
214            }
215        }
216        Ok(())
217    }
218}
219
220enum RegistryMessage {
221    Submit {
222        spec: Box<JobSpec>,
223        reply: Reply<JobStatus>,
224    },
225    Start {
226        name: String,
227        reply: Reply<JobStatus>,
228    },
229    Restart {
230        name: String,
231        reply: Reply<JobStatus>,
232    },
233    Drain {
234        name: String,
235        reply: Reply<JobStatus>,
236    },
237    Stop {
238        name: String,
239        reply: Reply<JobStatus>,
240    },
241    Status {
242        name: String,
243        reply: Reply<JobStatus>,
244    },
245    List {
246        reply: Reply<Vec<JobStatus>>,
247    },
248    Tick,
249    RestartReady {
250        name: String,
251        expected_generation: u64,
252    },
253    Shutdown(Reply<()>),
254}
255
256struct RegistryState {
257    config: AgentConfig,
258    materializer: Materializer,
259    events: Topic<JobEvent>,
260    instrumentation: StreamInstrumentationRegistry,
261    next_job_id: u64,
262    next_event_sequence: u64,
263    jobs: HashMap<String, JobEntry>,
264}
265
266struct JobEntry {
267    id: JobId,
268    spec: JobSpec,
269    state: JobState,
270    desired_state: DesiredJobState,
271    generation: u64,
272    run: Option<JobRun>,
273    backoff: RestartBackoffState,
274    starts_total: u64,
275    restarts_total: u64,
276    last_start_at: Option<SystemTime>,
277    last_exit_at: Option<SystemTime>,
278    last_exit_reason: Option<JobExitReason>,
279    backoff_until: Option<Instant>,
280    drain_deadline: Option<Instant>,
281    drain_supported: bool,
282}
283
284impl JobEntry {
285    fn new(id: JobId, spec: JobSpec) -> Self {
286        Self {
287            id,
288            spec,
289            state: JobState::Submitted,
290            desired_state: DesiredJobState::Stopped,
291            generation: 0,
292            run: None,
293            backoff: RestartBackoffState::default(),
294            starts_total: 0,
295            restarts_total: 0,
296            last_start_at: None,
297            last_exit_at: None,
298            last_exit_reason: None,
299            backoff_until: None,
300            drain_deadline: None,
301            drain_supported: true,
302        }
303    }
304}
305
306struct JobRun {
307    generation: u64,
308    control: JobControl,
309    completion: datum::StreamCompletion<NotUsed>,
310}
311
312#[derive(Default)]
313struct RestartBackoffState {
314    window_start: Option<Instant>,
315    restarts_in_window: usize,
316}
317
318impl RestartBackoffState {
319    fn reset(&mut self) {
320        self.window_start = None;
321        self.restarts_in_window = 0;
322    }
323
324    fn next_delay(&mut self, settings: &datum::RestartSettings) -> Option<Duration> {
325        let now = Instant::now();
326        match self.window_start {
327            Some(window_start)
328                if now.duration_since(window_start) <= settings.max_restarts_within() => {}
329            _ => {
330                self.window_start = Some(now);
331                self.restarts_in_window = 0;
332            }
333        }
334
335        if self.restarts_in_window >= settings.max_restarts() {
336            return None;
337        }
338
339        let exponent = (self.restarts_in_window.min(31) as i32).max(0);
340        let base = settings
341            .min_backoff()
342            .mul_f64(2_f64.powi(exponent))
343            .min(settings.max_backoff());
344        self.restarts_in_window += 1;
345
346        if settings.random_factor() == 0.0 || base.is_zero() {
347            Some(base)
348        } else {
349            let jitter = base.mul_f64(next_random_fraction() * settings.random_factor());
350            Some(base.saturating_add(jitter))
351        }
352    }
353}
354
355#[derive(Clone, Copy)]
356enum StartKind {
357    Initial,
358    AutomaticRestart { previous_generation: u64 },
359    ManualRestart,
360}
361
362fn submit_job(state: &mut RegistryState, spec: JobSpec) -> AgentResult<JobStatus> {
363    if spec.name.trim().is_empty() {
364        return Err(AgentError::InvalidJobName);
365    }
366    if state.jobs.contains_key(&spec.name) {
367        return Err(AgentError::JobAlreadyExists(spec.name));
368    }
369
370    let name = spec.name.clone();
371    let id = JobId(state.next_job_id);
372    state.next_job_id += 1;
373    state.jobs.insert(name.clone(), JobEntry::new(id, spec));
374    emit_event(state, &name, 0, JobEventKind::Submitted);
375    status_by_name(state, &name)
376}
377
378fn start_job(
379    state: &mut RegistryState,
380    myself: &ActorRef<RegistryMessage>,
381    name: &str,
382    kind: StartKind,
383) -> AgentResult<JobStatus> {
384    let (spec, job_id, generation) = {
385        let entry = state
386            .jobs
387            .get_mut(name)
388            .ok_or_else(|| AgentError::JobNotFound(name.to_owned()))?;
389
390        if matches!(
391            entry.state,
392            JobState::Starting | JobState::Running | JobState::Draining | JobState::BackingOff
393        ) && !matches!(kind, StartKind::AutomaticRestart { .. })
394        {
395            return Err(AgentError::JobAlreadyRunning(name.to_owned()));
396        }
397
398        entry.state = JobState::Starting;
399        entry.desired_state = DesiredJobState::Running;
400        entry.generation += 1;
401        entry.backoff_until = None;
402        entry.drain_deadline = None;
403        entry.run = None;
404        (entry.spec.clone(), entry.id, entry.generation)
405    };
406
407    let kill_switch = new_generation_kill_switch(&spec.name, generation);
408    let context = JobContext::new(
409        spec.name.clone(),
410        job_id,
411        generation,
412        kill_switch,
413        state.instrumentation.clone(),
414    );
415    let graph = invoke_factory(&spec, context);
416    let materializer = state
417        .materializer
418        .with_name_prefix(format!("datum-agent-{}", spec.name));
419    let materialized = graph.and_then(|graph| materialize_graph(&graph, &materializer));
420
421    match materialized {
422        Ok(mat) => {
423            let (completion, control) = mat.into_parts();
424            let mut restart_event = None;
425            {
426                let entry = state
427                    .jobs
428                    .get_mut(name)
429                    .ok_or_else(|| AgentError::JobNotFound(name.to_owned()))?;
430                entry.run = Some(JobRun {
431                    generation,
432                    control: control.clone(),
433                    completion,
434                });
435                entry.state = JobState::Running;
436                entry.desired_state = DesiredJobState::Running;
437                entry.starts_total += 1;
438                entry.last_start_at = Some(SystemTime::now());
439                entry.drain_supported = control.drain_supported();
440                if let StartKind::AutomaticRestart {
441                    previous_generation,
442                } = kind
443                {
444                    entry.restarts_total += 1;
445                    restart_event = Some(JobEventKind::Restarted {
446                        previous_generation,
447                    });
448                }
449            }
450            if let Some(kind) = restart_event {
451                emit_event(state, name, generation, kind);
452            }
453            emit_event(state, name, generation, JobEventKind::Started);
454            status_by_name(state, name)
455        }
456        Err(error) => {
457            handle_start_failure(state, myself, name, generation, error.clone());
458            Err(AgentError::Stream(error))
459        }
460    }
461}
462
463fn restart_job(
464    state: &mut RegistryState,
465    myself: &ActorRef<RegistryMessage>,
466    name: &str,
467) -> AgentResult<JobStatus> {
468    if !state.jobs.contains_key(name) {
469        return Err(AgentError::JobNotFound(name.to_owned()));
470    }
471    let _ = stop_job(state, name, JobExitReason::Stopped)?;
472    if let Some(entry) = state.jobs.get_mut(name) {
473        entry.backoff.reset();
474    }
475    start_job(state, myself, name, StartKind::ManualRestart)
476}
477
478fn drain_job(state: &mut RegistryState, name: &str) -> AgentResult<JobStatus> {
479    let generation = {
480        let entry = state
481            .jobs
482            .get_mut(name)
483            .ok_or_else(|| AgentError::JobNotFound(name.to_owned()))?;
484
485        let Some(run) = entry.run.as_ref() else {
486            entry.state = JobState::Drained;
487            entry.desired_state = DesiredJobState::Stopped;
488            entry.last_exit_at = Some(SystemTime::now());
489            entry.last_exit_reason = Some(JobExitReason::Drained);
490            return status_by_name(state, name);
491        };
492
493        let generation = run.generation;
494        let drain_supported = run.control.drain_supported();
495        let control = run.control.clone();
496        let timeout = match entry.spec.drain_behavior {
497            JobDrainBehavior::Graceful { timeout } if drain_supported => timeout,
498            JobDrainBehavior::Graceful { .. } | JobDrainBehavior::CancelOnly => {
499                return Err(AgentError::DrainUnsupported(name.to_owned()));
500            }
501        };
502
503        if !control.shutdown() {
504            return Err(AgentError::DrainUnsupported(name.to_owned()));
505        }
506
507        entry.state = JobState::Draining;
508        entry.desired_state = DesiredJobState::Draining;
509        let deadline = Instant::now() + timeout;
510        entry.drain_deadline = Some(deadline);
511        generation
512    };
513
514    emit_event(state, name, generation, JobEventKind::Draining);
515    status_by_name(state, name)
516}
517
518fn stop_job(
519    state: &mut RegistryState,
520    name: &str,
521    reason: JobExitReason,
522) -> AgentResult<JobStatus> {
523    let generation = {
524        let entry = state
525            .jobs
526            .get_mut(name)
527            .ok_or_else(|| AgentError::JobNotFound(name.to_owned()))?;
528
529        let generation = entry.generation;
530        if let Some(run) = entry.run.take() {
531            let _ = run.control.abort(StreamError::Cancelled);
532            drop(run.completion);
533        }
534        entry.state = JobState::Stopped;
535        entry.desired_state = DesiredJobState::Stopped;
536        entry.backoff_until = None;
537        entry.drain_deadline = None;
538        entry.last_exit_at = Some(SystemTime::now());
539        entry.last_exit_reason = Some(reason.clone());
540        generation
541    };
542
543    emit_event(state, name, generation, JobEventKind::Stopped { reason });
544    status_by_name(state, name)
545}
546
547fn status_by_name(state: &RegistryState, name: &str) -> AgentResult<JobStatus> {
548    state
549        .jobs
550        .get(name)
551        .map(|entry| snapshot(state, entry))
552        .ok_or_else(|| AgentError::JobNotFound(name.to_owned()))
553}
554
555fn list_statuses(state: &RegistryState) -> Vec<JobStatus> {
556    let mut statuses = state
557        .jobs
558        .values()
559        .map(|entry| snapshot(state, entry))
560        .collect::<Vec<_>>();
561    statuses.sort_by(|left, right| left.name.cmp(&right.name));
562    statuses
563}
564
565fn poll_jobs(state: &mut RegistryState, myself: &ActorRef<RegistryMessage>) {
566    let names = state.jobs.keys().cloned().collect::<Vec<_>>();
567    for name in names {
568        let completed = state.jobs.get_mut(&name).and_then(|entry| {
569            entry.run.as_mut().and_then(|run| {
570                run.completion
571                    .try_wait()
572                    .map(|result| (run.generation, result))
573            })
574        });
575
576        if let Some((generation, result)) = completed {
577            finish_generation(state, myself, &name, generation, result);
578        }
579    }
580
581    let now = Instant::now();
582    let timed_out = state
583        .jobs
584        .iter()
585        .filter_map(|(name, entry)| match (entry.state, entry.drain_deadline) {
586            (JobState::Draining, Some(deadline)) if deadline <= now => Some(name.clone()),
587            _ => None,
588        })
589        .collect::<Vec<_>>();
590
591    for name in timed_out {
592        let _ = stop_job(state, &name, JobExitReason::DrainTimedOut);
593    }
594}
595
596fn finish_generation(
597    state: &mut RegistryState,
598    myself: &ActorRef<RegistryMessage>,
599    name: &str,
600    generation: u64,
601    result: StreamResult<NotUsed>,
602) {
603    let mut restart = None;
604    let event;
605    {
606        let Some(entry) = state.jobs.get_mut(name) else {
607            return;
608        };
609        if entry.run.as_ref().map(|run| run.generation) != Some(generation) {
610            return;
611        }
612        let _run = entry.run.take();
613        entry.drain_deadline = None;
614        entry.last_exit_at = Some(SystemTime::now());
615
616        match (entry.desired_state, result) {
617            (DesiredJobState::Draining, Ok(_)) => {
618                entry.state = JobState::Drained;
619                entry.desired_state = DesiredJobState::Stopped;
620                entry.last_exit_reason = Some(JobExitReason::Drained);
621                event = JobEventKind::Drained;
622            }
623            (DesiredJobState::Draining | DesiredJobState::Stopped, Err(error)) => {
624                let reason = if matches!(error, StreamError::Cancelled) {
625                    JobExitReason::Stopped
626                } else {
627                    JobExitReason::Failed(error)
628                };
629                entry.state = JobState::Stopped;
630                entry.desired_state = DesiredJobState::Stopped;
631                entry.last_exit_reason = Some(reason.clone());
632                event = JobEventKind::Stopped { reason };
633            }
634            (DesiredJobState::Stopped, Ok(_)) => {
635                entry.state = JobState::Stopped;
636                entry.last_exit_reason = Some(JobExitReason::Stopped);
637                event = JobEventKind::Stopped {
638                    reason: JobExitReason::Stopped,
639                };
640            }
641            (DesiredJobState::Running, Ok(_)) => {
642                entry.last_exit_reason = Some(JobExitReason::Completed);
643                entry.state = JobState::Completed;
644                event = JobEventKind::Completed;
645                if let Some(settings) = entry
646                    .spec
647                    .restart_policy
648                    .settings_for(RestartCause::Completion)
649                {
650                    restart = Some((settings, RestartCause::Completion));
651                }
652            }
653            (DesiredJobState::Running, Err(error)) => {
654                let reason = JobExitReason::Failed(error);
655                entry.last_exit_reason = Some(reason.clone());
656                entry.state = JobState::Failed;
657                event = JobEventKind::Failed { reason };
658                if let Some(settings) = entry
659                    .spec
660                    .restart_policy
661                    .settings_for(RestartCause::Failure)
662                {
663                    restart = Some((settings, RestartCause::Failure));
664                }
665            }
666        }
667    }
668
669    emit_event(state, name, generation, event);
670    if let Some((settings, cause)) = restart {
671        schedule_restart(state, myself, name, generation, settings, cause);
672    }
673}
674
675fn handle_start_failure(
676    state: &mut RegistryState,
677    myself: &ActorRef<RegistryMessage>,
678    name: &str,
679    generation: u64,
680    error: StreamError,
681) {
682    let mut restart = None;
683    {
684        let Some(entry) = state.jobs.get_mut(name) else {
685            return;
686        };
687        let reason = JobExitReason::Failed(error);
688        entry.state = JobState::Failed;
689        entry.run = None;
690        entry.last_exit_at = Some(SystemTime::now());
691        entry.last_exit_reason = Some(reason.clone());
692        if let Some(settings) = entry
693            .spec
694            .restart_policy
695            .settings_for(RestartCause::Failure)
696        {
697            restart = Some(settings);
698        }
699    }
700
701    let reason = state
702        .jobs
703        .get(name)
704        .and_then(|entry| entry.last_exit_reason.clone())
705        .unwrap_or(JobExitReason::Failed(StreamError::AbruptTermination));
706    emit_event(state, name, generation, JobEventKind::Failed { reason });
707    if let Some(settings) = restart {
708        schedule_restart(
709            state,
710            myself,
711            name,
712            generation,
713            settings,
714            RestartCause::Failure,
715        );
716    }
717}
718
719fn schedule_restart(
720    state: &mut RegistryState,
721    myself: &ActorRef<RegistryMessage>,
722    name: &str,
723    generation: u64,
724    settings: datum::RestartSettings,
725    _cause: RestartCause,
726) {
727    let delay = {
728        let Some(entry) = state.jobs.get_mut(name) else {
729            return;
730        };
731        if entry.desired_state != DesiredJobState::Running {
732            return;
733        }
734        let Some(delay) = entry.backoff.next_delay(&settings) else {
735            entry.backoff_until = None;
736            return;
737        };
738        entry.state = JobState::BackingOff;
739        entry.backoff_until = Some(Instant::now() + delay);
740        delay
741    };
742
743    emit_event(
744        state,
745        name,
746        generation,
747        JobEventKind::RestartScheduled { delay },
748    );
749
750    let actor = myself.clone();
751    let name = name.to_owned();
752    tokio::spawn(async move {
753        tokio::time::sleep(delay).await;
754        let _ = actor.send_message(RegistryMessage::RestartReady {
755            name,
756            expected_generation: generation,
757        });
758    });
759}
760
761fn restart_ready(
762    state: &mut RegistryState,
763    myself: &ActorRef<RegistryMessage>,
764    name: &str,
765    expected_generation: u64,
766) {
767    let ready = state.jobs.get(name).is_some_and(|entry| {
768        entry.generation == expected_generation
769            && entry.state == JobState::BackingOff
770            && entry.desired_state == DesiredJobState::Running
771    });
772    if ready {
773        let _ = start_job(
774            state,
775            myself,
776            name,
777            StartKind::AutomaticRestart {
778                previous_generation: expected_generation,
779            },
780        );
781    }
782}
783
784fn abort_all_jobs(state: &mut RegistryState) {
785    let names = state.jobs.keys().cloned().collect::<Vec<_>>();
786    for name in names {
787        let _ = stop_job(state, &name, JobExitReason::Stopped);
788    }
789}
790
791fn snapshot(_state: &RegistryState, entry: &JobEntry) -> JobStatus {
792    JobStatus {
793        name: entry.spec.name.clone(),
794        job_id: entry.id,
795        state: entry.state,
796        desired_state: entry.desired_state,
797        generation: entry.generation,
798        starts_total: entry.starts_total,
799        restarts_total: entry.restarts_total,
800        last_start_at: entry.last_start_at,
801        last_exit_at: entry.last_exit_at,
802        last_exit_reason: entry.last_exit_reason.clone(),
803        backoff_until: entry.backoff_until,
804        drain_deadline: entry.drain_deadline,
805        drain_supported: entry.drain_supported,
806        active_streams: None,
807        instrumentation: None,
808        cluster: entry.spec.cluster.clone(),
809    }
810}
811
812fn emit_event(state: &mut RegistryState, name: &str, generation: u64, kind: JobEventKind) {
813    let Some(entry) = state.jobs.get(name) else {
814        return;
815    };
816    let event = JobEvent {
817        sequence: state.next_event_sequence,
818        timestamp: SystemTime::now(),
819        name: entry.spec.name.clone(),
820        job_id: entry.id,
821        generation,
822        kind,
823    };
824    state.next_event_sequence += 1;
825    match state.events.try_publish(event) {
826        Ok(()) | Err(TopicTryPublishError::Full(_)) | Err(TopicTryPublishError::Busy(_)) => {}
827        Err(TopicTryPublishError::Closed(_)) => {}
828    }
829}
830
831fn invoke_factory(spec: &JobSpec, context: JobContext) -> StreamResult<RunnableGraph<JobMat>> {
832    catch_unwind(AssertUnwindSafe(|| (spec.factory)(context)))
833        .unwrap_or_else(|_| Err(StreamError::Failed("job factory panicked".to_owned())))
834}
835
836fn materialize_graph(
837    graph: &RunnableGraph<JobMat>,
838    materializer: &Materializer,
839) -> StreamResult<JobMat> {
840    catch_unwind(AssertUnwindSafe(|| {
841        graph.run_with_materializer(materializer)
842    }))
843    .unwrap_or_else(|_| {
844        Err(StreamError::Failed(
845            "job materialization panicked".to_owned(),
846        ))
847    })
848}
849
850fn send_reply<T>(reply: Reply<T>, result: AgentResult<T>) {
851    let _ = reply.send(result);
852}
853
854fn spawn_completion_poller(actor: ActorRef<RegistryMessage>, interval: Duration) {
855    tokio::spawn(async move {
856        loop {
857            tokio::time::sleep(interval).await;
858            if actor.send_message(RegistryMessage::Tick).is_err() {
859                break;
860            }
861        }
862    });
863}
864
865fn agent_runtime() -> AgentResult<&'static TokioRuntime> {
866    static RUNTIME: OnceLock<Result<TokioRuntime, String>> = OnceLock::new();
867    match RUNTIME.get_or_init(|| {
868        TokioRuntimeBuilder::new_multi_thread()
869            .thread_name("datum-agent-ractor")
870            .enable_all()
871            .build()
872            .map_err(|error| format!("agent runtime failed to start: {error}"))
873    }) {
874        Ok(runtime) => Ok(runtime),
875        Err(error) => Err(AgentError::Actor(error.clone())),
876    }
877}
878
879fn block_on_agent_runtime<T, F>(future: F) -> AgentResult<T>
880where
881    T: Send + 'static,
882    F: Future<Output = T> + Send + 'static,
883{
884    let runtime = agent_runtime()?;
885    if tokio::runtime::Handle::try_current().is_ok() {
886        std::thread::spawn(move || runtime.block_on(future))
887            .join()
888            .map_err(|_| AgentError::Actor("agent runtime task panicked".to_owned()))
889    } else {
890        Ok(runtime.block_on(future))
891    }
892}
893
894fn next_random_fraction() -> f64 {
895    static BACKOFF_RANDOM_SEED: AtomicU64 = AtomicU64::new(0x9e37_79b9_7f4a_7c15);
896    let mut current = BACKOFF_RANDOM_SEED.load(Ordering::Relaxed);
897    loop {
898        let mut next = current;
899        next ^= next << 13;
900        next ^= next >> 7;
901        next ^= next << 17;
902        match BACKOFF_RANDOM_SEED.compare_exchange_weak(
903            current,
904            next,
905            Ordering::Relaxed,
906            Ordering::Relaxed,
907        ) {
908            Ok(_) => return ((next >> 11) as f64) / ((1_u64 << 53) as f64),
909            Err(observed) => current = observed,
910        }
911    }
912}