1use std::collections::{HashMap, HashSet};
30use std::sync::{Arc, Mutex};
31use std::time::{Duration, Instant};
32
33use improv_core_model::{MeasureId, RefreshPolicy};
34use improv_store::Store;
35
36use crate::AppState;
37
38#[derive(Clone, Default)]
40pub struct SchedulerStatus {
41 pub tick_secs: u64,
42 pub last_tick_refreshed: Vec<u32>,
45 pub last_error: Option<String>,
47}
48
49pub type SharedStatus = Arc<Mutex<SchedulerStatus>>;
50
51pub type Allowlist = Arc<HashSet<String>>;
53
54pub fn scheduler_enabled(env: Option<&str>) -> bool {
56 env.map(str::trim) == Some("1")
57}
58
59pub fn fn_digest(name: &str, body: &str) -> String {
61 use sha2::{Digest, Sha256};
62 let mut h = Sha256::new();
63 h.update(name.as_bytes());
64 h.update([0u8]);
65 h.update(body.as_bytes());
66 h.finalize().iter().map(|b| format!("{b:02x}")).collect()
67}
68
69pub fn parse_allowlist(text: &str) -> HashSet<String> {
72 text.lines()
73 .map(str::trim)
74 .filter(|l| !l.is_empty() && !l.starts_with('#'))
75 .map(str::to_ascii_lowercase)
76 .collect()
77}
78
79pub fn spawn_scheduler(
84 state: Arc<AppState>,
85 tick: Duration,
86 status: SharedStatus,
87 allow: Allowlist,
88) -> tokio::task::JoinHandle<()> {
89 tokio::spawn(async move {
90 let start = Instant::now();
91 let mut last_run: HashMap<MeasureId, u64> = HashMap::new();
95 eprintln!(
96 "scheduler: refreshing due CALL measures every {}s",
97 tick.as_secs()
98 );
99 loop {
100 tokio::time::sleep(tick).await;
101 match tick_once(&state, &allow, &mut last_run, start).await {
102 Ok(refreshed) => {
103 if let Ok(mut s) = status.lock() {
104 s.last_tick_refreshed = refreshed.iter().map(|m| m.0).collect();
105 s.last_error = None;
106 }
107 }
108 Err(e) => {
109 eprintln!("scheduler: tick error (continuing): {e}");
110 if let Ok(mut s) = status.lock() {
111 s.last_tick_refreshed.clear();
112 s.last_error = Some(e);
113 }
114 }
115 }
116 }
117 })
118}
119
120async fn tick_once(
124 state: &Arc<AppState>,
125 allow: &Allowlist,
126 last_run: &mut HashMap<MeasureId, u64>,
127 start: Instant,
128) -> Result<Vec<MeasureId>, String> {
129 let now_secs = start.elapsed().as_secs();
130 let path = state.db_path.clone();
131 let _write = state.write_lock.lock().await;
133 let allow = allow.clone();
134
135 let refreshed = tokio::task::spawn_blocking({
139 let last_run_snapshot = last_run.clone();
140 move || -> Result<Vec<MeasureId>, String> {
141 let mut store = Store::open(&path).map_err(|e| e.to_string())?;
142 let mut model = store.load_model().map_err(|e| e.to_string())?;
143
144 let policies: HashMap<MeasureId, RefreshPolicy> = model
145 .external_calls
146 .iter()
147 .map(|(id, c)| (*id, c.refresh_policy))
148 .collect();
149 let due =
150 improv_core_model::schedule::due_measures(&policies, now_secs, &last_run_snapshot);
151
152 let authored: Vec<(String, bool)> = model
156 .external_fns
157 .iter_mut()
158 .map(|(k, f)| (k.clone(), std::mem::replace(&mut f.pure, true)))
159 .collect();
160
161 let mut refreshed = Vec::new();
162 for mid in &due {
163 let allowed = model
164 .external_calls
165 .get(mid)
166 .and_then(|c| model.external_fns.get(&c.func))
167 .is_some_and(|f| allow.contains(&fn_digest(&f.name, &f.body)));
168 if !allowed {
169 eprintln!(
170 "scheduler: WARNING skipping measure {}: its function is not in \
171 the IMPROV_EXTFN_ALLOW allowlist",
172 mid.0
173 );
174 continue;
175 }
176 match improv_engine::external::refresh_external_measure(
177 &mut model,
178 improv_engine::external::DEFAULT_TIMEOUT,
179 *mid,
180 ) {
181 Ok(n) => {
182 eprintln!(
183 "scheduler: [{now_secs}s] refreshed measure {} ({n} cells)",
184 mid.0
185 );
186 refreshed.push(*mid);
187 }
188 Err(e) => {
189 eprintln!("scheduler: measure {} refresh failed: {e}", mid.0);
192 }
193 }
194 }
195 for (k, pure) in authored {
196 if let Some(f) = model.external_fns.get_mut(&k) {
197 f.pure = pure;
198 }
199 }
200 if !refreshed.is_empty() {
201 store.save_model(&model).map_err(|e| e.to_string())?;
202 }
203 Ok(refreshed)
204 }
205 })
206 .await
207 .map_err(|e| format!("task join: {e}"))??;
208
209 if !refreshed.is_empty() {
210 *state.cache.write().await = None;
212 }
213 for mid in &refreshed {
214 last_run.insert(*mid, now_secs);
215 }
216 Ok(refreshed)
217}
218
219#[cfg(test)]
220#[cfg_attr(coverage_nightly, coverage(off))]
221mod tests {
222 use super::*;
223 use improv_core_model::{
224 CategoryId, ExternalFn, ItemId, Language, Measure, MeasureKind, Model, Name, Value,
225 ValueType,
226 };
227 use improv_store::Store;
228
229 fn python_available() -> bool {
230 let probe = ExternalFn {
233 name: "probe".into(),
234 language: Language::Python,
235 body: "result = 1".into(),
236 arg_types: vec![],
237 return_type: ValueType::Number,
238 pure: true,
239 };
240 improv_extfn::eval(&probe, &[], std::time::Duration::from_secs(5)).is_ok()
241 }
242
243 fn temp_db_path(tag: &str) -> std::path::PathBuf {
244 let dir = std::env::temp_dir().join(format!("improv-sched-test-{}", std::process::id()));
245 std::fs::create_dir_all(&dir).unwrap();
246 let path = dir.join(format!(
247 "{tag}-{}.db",
248 std::time::SystemTime::now()
249 .duration_since(std::time::UNIX_EPOCH)
250 .unwrap()
251 .as_nanos()
252 ));
253 let _ = std::fs::remove_file(&path);
254 path
255 }
256
257 fn call_model(policy: RefreshPolicy) -> Model {
260 let mut m = Model::new();
261 let product = CategoryId(2);
262 m.add_category(product, "Product");
263 m.add_item(ItemId(20), product, "A");
264 m.add_measure(Measure {
265 id: MeasureId(100),
266 name: Name("X".into()),
267 value_type: ValueType::Number,
268 categories: vec![product],
269 kind: MeasureKind::Input,
270 description: None,
271 });
272 m.set_input(
273 MeasureId(100),
274 improv_core_model::Coordinate::from_pairs([(product, ItemId(20))]),
275 Value::Number(3.0),
276 );
277 m.add_measure(Measure {
278 id: MeasureId(200),
279 name: Name("H".into()),
280 value_type: ValueType::Number,
281 categories: vec![product],
282 kind: MeasureKind::Input, description: None,
284 });
285 m.external_fns.insert(
286 "double".into(),
287 ExternalFn {
288 name: "double".into(),
289 language: Language::Python,
290 body: "result = args[0] * 2".into(),
291 arg_types: vec![ValueType::Number],
292 return_type: ValueType::Number,
293 pure: true,
294 },
295 );
296 m.external_calls.insert(
297 MeasureId(200),
298 improv_core_model::ExternalCall {
299 func: "double".into(),
300 arg_measures: vec![MeasureId(100)],
301 refresh_policy: policy,
302 },
303 );
304 m
305 }
306
307 fn app_state(db_path: &str) -> Arc<AppState> {
308 Arc::new(AppState::new(
309 db_path.to_string(),
310 crate::Auth::Disabled,
311 None,
312 ))
313 }
314
315 #[tokio::test]
318 async fn scheduler_refreshes_due_call_measure() {
319 if !python_available() {
320 println!("skipped: python3 not found");
321 return;
322 }
323 let path = temp_db_path("due");
324 let mut store = Store::open(path.to_str().unwrap()).unwrap();
325 store
326 .save_model(&call_model(RefreshPolicy::Interval { secs: 0 }))
327 .unwrap();
328
329 let state = app_state(path.to_str().unwrap());
330 let status: SharedStatus = Arc::new(Mutex::new(SchedulerStatus {
331 tick_secs: 0,
332 ..Default::default()
333 }));
334 let f = &call_model(RefreshPolicy::Manual).external_fns["double"];
335 let allow: Allowlist = Arc::new(HashSet::from([fn_digest(&f.name, &f.body)]));
336 let handle = spawn_scheduler(state, Duration::from_millis(50), status.clone(), allow);
337
338 let mut refreshed_val = None;
341 for _ in 0..50 {
342 tokio::time::sleep(Duration::from_millis(100)).await;
343 let mut probe = Store::open(path.to_str().unwrap()).unwrap();
344 let model = probe.load_model().unwrap();
345 let coord = improv_core_model::Coordinate::from_pairs([(CategoryId(2), ItemId(20))]);
346 if let Some(v) = model.input(MeasureId(200), &coord) {
347 refreshed_val = Some(v.clone());
348 break;
349 }
350 }
351 let mut saw_status = false;
354 for _ in 0..20 {
355 if status.lock().unwrap().last_tick_refreshed.contains(&200) {
356 saw_status = true;
357 break;
358 }
359 tokio::time::sleep(Duration::from_millis(20)).await;
360 }
361 handle.abort();
362
363 assert_eq!(
364 refreshed_val,
365 Some(Value::Number(6.0)),
366 "scheduler should have refreshed H = double(X) = 6"
367 );
368 assert!(saw_status, "status should report the refreshed measure");
369 }
370
371 #[tokio::test]
374 async fn no_due_measures_is_a_noop() {
375 let path = temp_db_path("noop");
376 let mut store = Store::open(path.to_str().unwrap()).unwrap();
377 store
378 .save_model(&call_model(RefreshPolicy::Manual))
379 .unwrap();
380 let tx_before = store.tx_high_water().unwrap();
384 drop(store);
385
386 let state = app_state(path.to_str().unwrap());
387 let mut last_run = HashMap::new();
388 let refreshed = tick_once(&state, &Allowlist::default(), &mut last_run, Instant::now())
389 .await
390 .expect("a no-op tick must not error");
391 assert!(refreshed.is_empty());
392
393 let tx_after = Store::open(path.to_str().unwrap())
394 .unwrap()
395 .tx_high_water()
396 .unwrap();
397 assert_eq!(
398 tx_before, tx_after,
399 "a tick that refreshes nothing must not write to the store"
400 );
401 }
402
403 #[tokio::test]
407 async fn refresh_error_does_not_kill_the_loop() {
408 let path = temp_db_path("err");
409 let mut model = call_model(RefreshPolicy::Interval { secs: 0 });
410 model.external_calls.get_mut(&MeasureId(200)).unwrap().func = "nope".into();
412 let mut store = Store::open(path.to_str().unwrap()).unwrap();
413 store.save_model(&model).unwrap();
414
415 let state = app_state(path.to_str().unwrap());
416 let status: SharedStatus = Arc::new(Mutex::new(SchedulerStatus::default()));
417 let handle = spawn_scheduler(
418 state,
419 Duration::from_millis(50),
420 status,
421 Allowlist::default(),
422 );
423
424 tokio::time::sleep(Duration::from_millis(200)).await;
425 assert!(
426 !handle.is_finished(),
427 "a per-measure refresh error must not end the scheduler task"
428 );
429 handle.abort();
430 }
431
432 #[tokio::test]
434 async fn unlisted_function_is_skipped() {
435 let path = temp_db_path("unlisted");
436 let mut store = Store::open(path.to_str().unwrap()).unwrap();
437 store
438 .save_model(&call_model(RefreshPolicy::Interval { secs: 0 }))
439 .unwrap();
440 let state = app_state(path.to_str().unwrap());
441 let other: Allowlist = Arc::new(HashSet::from([fn_digest("double", "evil")]));
442 let refreshed = tick_once(&state, &other, &mut HashMap::new(), Instant::now())
443 .await
444 .unwrap();
445 assert!(refreshed.is_empty(), "unlisted function must be skipped");
446 let model = Store::open(path.to_str().unwrap())
447 .unwrap()
448 .load_model()
449 .unwrap();
450 let coord = improv_core_model::Coordinate::from_pairs([(CategoryId(2), ItemId(20))]);
451 assert!(model.input(MeasureId(200), &coord).is_none());
452 }
453
454 #[test]
455 fn scheduler_is_off_by_default() {
456 assert!(!scheduler_enabled(None));
457 assert!(!scheduler_enabled(Some("0")));
458 assert!(!scheduler_enabled(Some("true")));
459 assert!(scheduler_enabled(Some("1")));
460 }
461
462 #[test]
463 fn digest_and_allowlist_parse() {
464 let d = fn_digest("double", "result = args[0] * 2");
465 assert_eq!(d.len(), 64);
466 assert_ne!(d, fn_digest("doubl", "eresult = args[0] * 2"));
467 let set = parse_allowlist(&format!("# ops\n\n {} \n", d.to_uppercase()));
468 assert_eq!(set, HashSet::from([d]));
469 }
470}