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