Skip to main content

chronon_runtime/
builder.rs

1//! [`ChrononBuilder`] and [`DeploymentShape`] — runtime configuration and assembly.
2
3use std::sync::Arc;
4
5use chronon_core::context::{ContextFactory, NoOpContextFactory};
6use chronon_core::error::{ChrononError, Result};
7use chronon_core::store::SchedulerStore;
8use chronon_executor::{
9    event_channel_capacity_from_env, executor_concurrency_from_env, Executor, ScriptRegistry,
10};
11use chronon_scheduler::{Scheduler, SchedulerConfig};
12use chronon_telemetry::{NoOpSink, TelemetrySink};
13use tokio::sync::{mpsc, Notify};
14
15/// Named deployment assembly — process topology shape.
16///
17/// Selected via [`ChrononBuilder`] fluent methods; drives which loops
18/// [`crate::Chronon::run`] starts. Maps to the public crate getting-started topologies:
19///
20/// | Shape | Builder | Local loops | When to use |
21/// |-------|---------|-------------|-------------|
22/// | [`Self::Embedded`] | [`.embedded()`](ChrononBuilder::embedded) | Tick **and** worker | Embedded — one process |
23/// | [`Self::CoordinatorOnly`] | [`.coordinator_only()`](ChrononBuilder::coordinator_only) | Tick only | Coordinator–worker coordinator binary |
24/// | [`Self::Worker`] | [`.worker(pool)`](ChrononBuilder::worker) | Claim + execute | Coordinator–worker worker binary(ies) |
25/// | [`Self::RemoteClient`] | [`.remote_coordinator(url)`](ChrononBuilder::remote_coordinator) | **None** | Remote HTTP client — schedule via HTTP |
26///
27/// Coordinator–worker and remote HTTP client need a shared durable store (Postgres, usually +
28/// Redis) on the host that runs Chronon loops. See [`crate::RemoteCoordinatorClient`] for the
29/// HTTP client path.
30///
31/// # Examples
32///
33/// ```
34/// use chronon_runtime::DeploymentShape;
35///
36/// assert_eq!(DeploymentShape::default(), DeploymentShape::Embedded);
37/// let worker = DeploymentShape::Worker("general".into());
38/// assert!(matches!(worker, DeploymentShape::Worker(_)));
39/// ```
40#[derive(Debug, Clone, PartialEq, Eq, Default)]
41pub enum DeploymentShape {
42    /// Coordinator tick loop + worker in one process (embedded default).
43    #[default]
44    Embedded,
45    /// Scheduler tick and partition assigner only; no script execution (coordinator binary).
46    CoordinatorOnly,
47    /// Worker loop for `pool_id`; claims runs from the shared store (worker binary).
48    Worker(String),
49    /// No local loops; host uses [`crate::RemoteCoordinatorClient`] against `base_url`.
50    RemoteClient(String),
51}
52
53/// Builds a [`crate::Chronon`] runtime with explicit adapter injection.
54///
55/// Hosts call fluent setters then [`Self::build`]. Missing store is the only hard requirement;
56/// context factory, telemetry, and registry fall back to no-op defaults.
57///
58/// Choose topology with [`.embedded()`](Self::embedded) / [`.coordinator_only()`](Self::coordinator_only) /
59/// [`.worker()`](Self::worker) / [`.remote_coordinator()`](Self::remote_coordinator) — see
60/// [`DeploymentShape`]. Use [`.auto_registry()`](Self::auto_registry) to pick up `#[chronon::script]`
61/// handlers linked into this binary (required on coordinator–worker **workers**).
62///
63/// # Examples
64///
65/// Embedded with an empty registry:
66///
67/// ```
68/// use std::sync::Arc;
69/// use chronon_backend_mem::InMemorySchedulerStore;
70/// use chronon_runtime::{ChrononBuilder, DeploymentShape};
71///
72/// let store = Arc::new(InMemorySchedulerStore::new());
73/// let chronon = ChrononBuilder::new()
74///     .scheduler_store(store)
75///     .embedded()
76///     .build()
77///     .unwrap();
78/// assert_eq!(chronon.deployment, DeploymentShape::Embedded);
79/// assert_eq!(chronon.executor().script_count(), 0);
80/// ```
81///
82/// Worker shape (scripts must be registered on this binary):
83///
84/// ```
85/// use std::sync::Arc;
86/// use chronon_backend_mem::InMemorySchedulerStore;
87/// use chronon_runtime::{ChrononBuilder, DeploymentShape};
88///
89/// let chronon = ChrononBuilder::new()
90///     .scheduler_store(Arc::new(InMemorySchedulerStore::new()))
91///     .instance_id("worker-a")
92///     .worker("general")
93///     .build()
94///     .unwrap();
95/// assert!(matches!(chronon.deployment, DeploymentShape::Worker(_)));
96/// ```
97pub struct ChrononBuilder {
98    store: Option<Arc<dyn SchedulerStore>>,
99    context_factory: Option<Arc<dyn ContextFactory>>,
100    telemetry: Option<Arc<dyn TelemetrySink>>,
101    registry: Option<Arc<ScriptRegistry>>,
102    deployment: DeploymentShape,
103    auto_registry: bool,
104    tick_interval_ms: u64,
105    instance_id: Option<String>,
106}
107
108impl ChrononBuilder {
109    /// Empty builder: embedded deployment, env-default tick interval, no store.
110    pub fn new() -> Self {
111        Self {
112            store: None,
113            context_factory: None,
114            telemetry: None,
115            registry: None,
116            deployment: DeploymentShape::Embedded,
117            auto_registry: false,
118            tick_interval_ms: chronon_scheduler::tick_interval_ms_from_env(),
119            instance_id: None,
120        }
121    }
122
123    /// Required unless [`Self::scheduler_store_from_global`] is used.
124    pub fn scheduler_store(mut self, store: Arc<dyn SchedulerStore>) -> Self {
125        self.store = Some(store);
126        self
127    }
128
129    /// Installs the process-global default store (e.g. mem backend); errors if unset.
130    pub fn scheduler_store_from_global(mut self) -> Result<Self> {
131        self.store = Some(chronon_core::default_store_from_global()?);
132        Ok(self)
133    }
134
135    /// Factory used when executing scripts; defaults to [`NoOpContextFactory`].
136    pub fn context_factory(mut self, factory: Arc<dyn ContextFactory>) -> Self {
137        self.context_factory = Some(factory);
138        self
139    }
140
141    /// Metrics sink shared by scheduler and executor; defaults to [`NoOpSink`].
142    pub fn telemetry_sink(mut self, sink: Arc<dyn TelemetrySink>) -> Self {
143        self.telemetry = Some(sink);
144        self
145    }
146
147    /// Script registry for the executor; use [`Self::auto_registry`] to populate from inventory.
148    pub fn script_registry(mut self, registry: Arc<ScriptRegistry>) -> Self {
149        self.registry = Some(registry);
150        self
151    }
152
153    /// Stable id for scheduler leader election and worker rows; random UUID if omitted.
154    pub fn instance_id(mut self, id: impl Into<String>) -> Self {
155        self.instance_id = Some(id.into());
156        self
157    }
158
159    /// Embedded coordinator + worker loops in one process.
160    pub fn embedded(mut self) -> Self {
161        self.deployment = DeploymentShape::Embedded;
162        self
163    }
164
165    /// Coordinator-only: tick and partition assigner, no worker slots.
166    pub fn coordinator_only(mut self) -> Self {
167        self.deployment = DeploymentShape::CoordinatorOnly;
168        self
169    }
170
171    /// Worker-only: claim and execute runs for `pool_id`.
172    pub fn worker(mut self, pool_id: impl Into<String>) -> Self {
173        self.deployment = DeploymentShape::Worker(pool_id.into());
174        self
175    }
176
177    /// Remote client shape: no local loops; pair with [`crate::RemoteCoordinatorClient`].
178    ///
179    /// [`crate::Chronon::run`] returns an error for this shape — schedule via the HTTP client.
180    pub fn remote_coordinator(mut self, base_url: impl Into<String>) -> Self {
181        self.deployment = DeploymentShape::RemoteClient(base_url.into());
182        self
183    }
184
185    /// Populate registry from `inventory` (`#[chronon::script]` link-time registration).
186    ///
187    /// In a coordinator–worker split, call this on **worker** binaries (that is where scripts
188    /// execute).
189    pub fn auto_registry(mut self) -> Self {
190        self.auto_registry = true;
191        self
192    }
193
194    /// Scheduler tick period in milliseconds; overrides `CHRONON_TICK_INTERVAL_MS` when set.
195    pub fn tick_interval_ms(mut self, ms: u64) -> Self {
196        self.tick_interval_ms = ms;
197        self
198    }
199
200    /// Assemble [`crate::Chronon`]; returns [`ChrononError::Internal`] if store was not configured.
201    pub fn build(self) -> Result<super::Chronon> {
202        let store = self
203            .store
204            .ok_or_else(|| ChrononError::Internal("scheduler_store is required".into()))?;
205        let context_factory = self
206            .context_factory
207            .unwrap_or_else(|| Arc::new(NoOpContextFactory));
208        let telemetry = self
209            .telemetry
210            .unwrap_or_else(|| Arc::new(NoOpSink) as Arc<dyn TelemetrySink>);
211        let registry = match self.registry {
212            Some(registry) => registry,
213            None if self.auto_registry => Arc::new(ScriptRegistry::from_inventory()),
214            None => Arc::new(ScriptRegistry::new()),
215        };
216
217        let embedded_partitions = matches!(self.deployment, DeploymentShape::Embedded);
218        let instance_id = self
219            .instance_id
220            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
221
222        let scheduler = Arc::new(Scheduler::new(
223            SchedulerConfig {
224                tick_interval_ms: self.tick_interval_ms,
225                instance_id,
226                num_partitions: chronon_scheduler::num_partitions_from_env(),
227                embedded: embedded_partitions,
228            },
229            store.clone(),
230            telemetry.clone(),
231        ));
232
233        let (event_tx, event_rx) = mpsc::channel(event_channel_capacity_from_env());
234        let executor = Arc::new(Executor::new(
235            registry,
236            context_factory,
237            telemetry,
238            event_tx,
239            executor_concurrency_from_env(),
240        ));
241
242        Ok(super::Chronon::new(
243            store,
244            scheduler,
245            executor,
246            self.deployment,
247            Arc::new(Notify::new()),
248            event_rx,
249        ))
250    }
251}
252
253impl Default for ChrononBuilder {
254    fn default() -> Self {
255        Self::new()
256    }
257}
258
259/// Shorthand for [`ChrononBuilder::new`].
260pub fn builder() -> ChrononBuilder {
261    ChrononBuilder::new()
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use chronon_backend_mem::InMemorySchedulerStore;
268    use chronon_telemetry::ConsoleSink;
269
270    #[tokio::test]
271    async fn builder_embedded_compiles() {
272        let store = Arc::new(InMemorySchedulerStore::new());
273        let chronon = ChrononBuilder::new()
274            .scheduler_store(store)
275            .telemetry_sink(Arc::new(ConsoleSink))
276            .embedded()
277            .build()
278            .expect("build");
279        assert_eq!(chronon.deployment, DeploymentShape::Embedded);
280    }
281
282    #[tokio::test]
283    async fn builder_auto_registry_from_inventory() {
284        let store = Arc::new(InMemorySchedulerStore::new());
285        let chronon = ChrononBuilder::new()
286            .scheduler_store(store)
287            .embedded()
288            .auto_registry()
289            .build()
290            .expect("build");
291        let _ = chronon.executor().script_count();
292    }
293
294    #[tokio::test]
295    async fn builder_scheduler_store_from_global() {
296        let _installed = chronon_backend_mem::install_default_mem_store();
297        let chronon = ChrononBuilder::new()
298            .scheduler_store_from_global()
299            .expect("global store")
300            .embedded()
301            .build()
302            .expect("build");
303        assert_eq!(chronon.deployment, DeploymentShape::Embedded);
304    }
305}