chronon_executor/registry.rs
1//! In-memory [`ScriptRegistry`] with optional link-time inventory discovery.
2
3use std::collections::HashMap;
4
5use chronon_core::{ChrononError, Result};
6
7use crate::descriptor::{InvokeFn, ScriptDescriptor};
8
9struct StoredScript {
10 name: String,
11 invoke: InvokeFn,
12 signature_json: String,
13 signature_hash: u64,
14}
15
16/// In-memory script registry with optional link-time inventory discovery.
17///
18/// Populated at boot via [`Self::from_inventory`] / [`Self::register_from_inventory`] (from
19/// `#[chronon::script]`) or manual [`Self::register`] calls. Read by the executor when a run is
20/// claimed.
21///
22/// In Mode 2 (coordinator + worker), scripts must be registered on **worker** binaries —
23/// that is where handlers execute. Prefer `ChrononBuilder::auto_registry()` so inventory is
24/// collected automatically.
25///
26/// # Examples
27///
28/// Empty registry:
29///
30/// ```
31/// use chronon_executor::ScriptRegistry;
32///
33/// let registry = ScriptRegistry::new();
34/// assert!(registry.is_empty());
35/// ```
36///
37/// Explicit register (daemon-style, without the macro):
38///
39/// ```
40/// use chronon_core::{Result, ScriptContext};
41/// use chronon_executor::{ScriptDescriptor, ScriptRegistry};
42///
43/// fn noop(
44/// _ctx: Box<dyn ScriptContext>,
45/// _params: serde_json::Value,
46/// ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send>> {
47/// Box::pin(async { Ok(()) })
48/// }
49///
50/// let mut registry = ScriptRegistry::new();
51/// registry.register(ScriptDescriptor::new("daemon-noop", noop));
52/// assert!(registry.contains("daemon-noop"));
53/// assert_eq!(registry.len(), 1);
54/// ```
55pub struct ScriptRegistry {
56 scripts: HashMap<String, StoredScript>,
57}
58
59impl Default for ScriptRegistry {
60 fn default() -> Self {
61 Self::new()
62 }
63}
64
65impl ScriptRegistry {
66 /// Creates an empty registry with no registered scripts.
67 ///
68 /// # Examples
69 ///
70 /// ```
71 /// use chronon_executor::ScriptRegistry;
72 ///
73 /// let registry = ScriptRegistry::new();
74 /// assert!(registry.is_empty());
75 /// ```
76 pub fn new() -> Self {
77 Self {
78 scripts: HashMap::new(),
79 }
80 }
81
82 /// Populate from all `#[chronon::script]` descriptors linked into the binary.
83 ///
84 /// Equivalent to `ChrononBuilder::auto_registry()` wiring. Prefer this on Mode 2 workers.
85 pub fn from_inventory() -> Self {
86 let mut registry = Self::new();
87 registry.register_from_inventory();
88 registry
89 }
90
91 /// Register every script descriptor collected via link-time inventory.
92 pub fn register_from_inventory(&mut self) {
93 for desc in quark::inventory::iter::<ScriptDescriptor> {
94 self.register(ScriptDescriptor::with_signature(
95 desc.name,
96 desc.invoke,
97 desc.signature_json,
98 desc.signature_hash,
99 ));
100 }
101 }
102
103 /// Inserts or replaces a script descriptor keyed by [`ScriptDescriptor::name`].
104 ///
105 /// Duplicate names overwrite the previous handler without error.
106 pub fn register(&mut self, desc: ScriptDescriptor) {
107 self.scripts.insert(
108 desc.name.to_string(),
109 StoredScript {
110 name: desc.name.to_string(),
111 invoke: desc.invoke,
112 signature_json: desc.signature_json.to_string(),
113 signature_hash: desc.signature_hash,
114 },
115 );
116 }
117
118 /// Returns a borrowed view of the script named `name`, if registered.
119 pub fn get(&self, name: &str) -> Option<ScriptDescriptorRef<'_>> {
120 self.scripts.get(name).map(|s| ScriptDescriptorRef {
121 name: s.name.as_str(),
122 invoke: s.invoke,
123 signature_json: s.signature_json.as_str(),
124 signature_hash: s.signature_hash,
125 })
126 }
127
128 /// Like [`Self::get`], but returns [`ChrononError::ScriptNotFound`] when absent.
129 pub fn get_or_err(&self, name: &str) -> Result<ScriptDescriptorRef<'_>> {
130 self.get(name)
131 .ok_or_else(|| ChrononError::ScriptNotFound(name.to_string()))
132 }
133
134 /// Returns the number of registered scripts.
135 pub fn len(&self) -> usize {
136 self.scripts.len()
137 }
138
139 /// Returns `true` when no scripts are registered.
140 pub fn is_empty(&self) -> bool {
141 self.scripts.is_empty()
142 }
143
144 /// Returns `true` when a script with the given name is registered.
145 pub fn contains(&self, name: &str) -> bool {
146 self.scripts.contains_key(name)
147 }
148
149 /// Returns all registered scripts sorted by name.
150 pub fn list(&self) -> Vec<ScriptDescriptorRef<'_>> {
151 let mut v: Vec<_> = self.scripts.values().map(stored_to_ref).collect();
152 v.sort_by_key(|d| d.name);
153 v
154 }
155}
156
157/// Borrowed view of a registered script suitable for invocation.
158#[derive(Clone, Copy)]
159pub struct ScriptDescriptorRef<'a> {
160 /// Registered script name (matches job `script_name`).
161 pub name: &'a str,
162 /// Handler function pointer collected from inventory or manual registration.
163 pub invoke: InvokeFn,
164 /// JSON schema string for handler parameters (from `chronon-macros` when available).
165 pub signature_json: &'a str,
166 /// Compile-time hash of the signature for drift detection.
167 pub signature_hash: u64,
168}
169
170fn stored_to_ref(s: &StoredScript) -> ScriptDescriptorRef<'_> {
171 ScriptDescriptorRef {
172 name: s.name.as_str(),
173 invoke: s.invoke,
174 signature_json: s.signature_json.as_str(),
175 signature_hash: s.signature_hash,
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use crate::descriptor::ScriptDescriptor;
183 use chronon_core::{Result, ScriptContext};
184 use serde_json::Value;
185 use std::future::Future;
186 use std::pin::Pin;
187
188 fn stub_invoke(
189 _ctx: Box<dyn ScriptContext>,
190 _params: Value,
191 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
192 Box::pin(async { Ok(()) })
193 }
194
195 #[test]
196 fn register_and_lookup() {
197 let mut reg = ScriptRegistry::new();
198 reg.register(ScriptDescriptor::new("hello", stub_invoke));
199 assert!(reg.contains("hello"));
200 assert_eq!(reg.len(), 1);
201 }
202}