Skip to main content

cloudillo_core/
scheduler.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Scheduler subsystem. Handles async tasks, dependencies, fallbacks, repetitions, persistence..
5
6use async_trait::async_trait;
7use itertools::Itertools;
8use std::{
9	collections::{BTreeMap, HashMap},
10	fmt::Debug,
11	sync::{Arc, Mutex, RwLock},
12};
13
14use chrono::{DateTime, Utc};
15use croner::Cron;
16use std::str::FromStr;
17
18use crate::prelude::*;
19use cloudillo_types::{lock, meta_adapter};
20
21pub type TaskId = u64;
22
23pub enum TaskType {
24	Periodic,
25	Once,
26}
27
28/// Cron schedule wrapper using the croner crate
29/// Stores the expression string for serialization
30#[derive(Debug, Clone)]
31pub struct CronSchedule {
32	/// The original cron expression string
33	expr: Box<str>,
34	/// Parsed cron object
35	cron: Cron,
36}
37
38impl CronSchedule {
39	/// Parse a cron expression (5 fields: minute hour day month weekday)
40	pub fn parse(expr: &str) -> ClResult<Self> {
41		let cron = Cron::from_str(expr)
42			.map_err(|e| Error::ValidationError(format!("invalid cron expression: {}", e)))?;
43		Ok(Self { expr: expr.into(), cron })
44	}
45
46	/// Calculate the next execution time after the given timestamp
47	///
48	/// Returns an error if no next occurrence can be found (should be rare
49	/// for valid expressions within reasonable time bounds).
50	pub fn next_execution(&self, after: Timestamp) -> ClResult<Timestamp> {
51		let dt = DateTime::<Utc>::from_timestamp(after.0, 0).unwrap_or_else(Utc::now);
52
53		self.cron
54			.find_next_occurrence(&dt, false)
55			.map(|next| Timestamp(next.timestamp()))
56			.map_err(|e| {
57				tracing::error!("Failed to find next cron occurrence for '{}': {}", self.expr, e);
58				Error::ValidationError(format!("cron next_execution failed: {}", e))
59			})
60	}
61
62	/// Convert back to cron expression string
63	pub fn to_cron_string(&self) -> String {
64		self.expr.to_string()
65	}
66}
67
68impl PartialEq for CronSchedule {
69	fn eq(&self, other: &Self) -> bool {
70		self.expr == other.expr
71	}
72}
73
74impl Eq for CronSchedule {}
75
76#[async_trait]
77pub trait Task<S: Clone>: Send + Sync + Debug {
78	fn kind() -> &'static str
79	where
80		Self: Sized;
81	fn build(id: TaskId, context: &str) -> ClResult<Arc<dyn Task<S>>>
82	where
83		Self: Sized;
84	fn serialize(&self) -> String;
85	async fn run(&self, state: &S) -> ClResult<()>;
86
87	fn kind_of(&self) -> &'static str;
88
89	/// Called when the task transitions to `Failed` after exhausting retries
90	/// (or on the very first failure when no retry policy is set). Lets the
91	/// task perform irreversible cleanup — e.g. mark a related domain row as
92	/// permanently failed — that should not happen on retryable failures.
93	/// Default: no-op.
94	async fn on_failed(&self, _state: &S, _attempts: u16, _last_error: &str) {}
95
96	/// Called after a failure that **will** be retried, with the zero-based index
97	/// of the attempt that just failed. Lets a task tell the user once, on the
98	/// first failure, instead of once per retry. Default: no-op.
99	///
100	/// Mutually exclusive with [`Task::on_failed`] for any single failure: a
101	/// failure either schedules a retry (this) or is terminal (that).
102	async fn on_attempt_failed(&self, _state: &S, _attempt: u16, _last_error: &str) {}
103}
104
105#[derive(Debug)]
106pub enum TaskStatus {
107	Pending,
108	Completed,
109	Failed,
110}
111
112pub struct TaskData {
113	id: TaskId,
114	kind: Box<str>,
115	status: TaskStatus,
116	input: Box<str>,
117	deps: Box<[TaskId]>,
118	retry_data: Option<Box<str>>,
119	cron_data: Option<Box<str>>,
120	next_at: Option<Timestamp>,
121}
122
123#[async_trait]
124pub trait TaskStore<S: Clone>: Send + Sync {
125	async fn add(&self, task: &TaskMeta<S>, key: Option<&str>) -> ClResult<TaskId>;
126	async fn finished(&self, id: TaskId, output: &str) -> ClResult<()>;
127	async fn load(&self) -> ClResult<Vec<TaskData>>;
128	async fn update_task_error(
129		&self,
130		task_id: TaskId,
131		output: &str,
132		next_at: Option<Timestamp>,
133	) -> ClResult<()>;
134	async fn find_by_key(&self, key: &str) -> ClResult<Option<(TaskId, TaskData)>>;
135	async fn update_task(&self, id: TaskId, task: &TaskMeta<S>) -> ClResult<()>;
136	async fn find_completed_deps(&self, deps: &[TaskId]) -> ClResult<Vec<TaskId>>;
137}
138
139// InMemoryTaskStore
140//*******************
141pub struct InMemoryTaskStore {
142	last_id: Mutex<TaskId>,
143}
144
145impl InMemoryTaskStore {
146	pub fn new() -> Arc<Self> {
147		Arc::new(Self { last_id: Mutex::new(0) })
148	}
149}
150
151#[async_trait]
152impl<S: Clone> TaskStore<S> for InMemoryTaskStore {
153	async fn add(&self, _task: &TaskMeta<S>, _key: Option<&str>) -> ClResult<TaskId> {
154		let mut last_id = lock!(self.last_id)?;
155		*last_id += 1;
156		Ok(*last_id)
157	}
158
159	async fn finished(&self, _id: TaskId, _output: &str) -> ClResult<()> {
160		Ok(())
161	}
162
163	async fn load(&self) -> ClResult<Vec<TaskData>> {
164		Ok(vec![])
165	}
166
167	async fn update_task_error(
168		&self,
169		_task_id: TaskId,
170		_output: &str,
171		_next_at: Option<Timestamp>,
172	) -> ClResult<()> {
173		Ok(())
174	}
175
176	async fn find_by_key(&self, _key: &str) -> ClResult<Option<(TaskId, TaskData)>> {
177		// In-memory store doesn't support persistence or keys
178		Ok(None)
179	}
180
181	async fn update_task(&self, _id: TaskId, _task: &TaskMeta<S>) -> ClResult<()> {
182		// In-memory store doesn't support persistence
183		Ok(())
184	}
185
186	async fn find_completed_deps(&self, _deps: &[TaskId]) -> ClResult<Vec<TaskId>> {
187		Ok(vec![])
188	}
189}
190
191// MetaAdapterTaskStore
192//**********************
193pub struct MetaAdapterTaskStore {
194	meta_adapter: Arc<dyn meta_adapter::MetaAdapter>,
195}
196
197impl MetaAdapterTaskStore {
198	pub fn new(meta_adapter: Arc<dyn meta_adapter::MetaAdapter>) -> Arc<Self> {
199		Arc::new(Self { meta_adapter })
200	}
201}
202
203#[async_trait]
204impl<S: Clone> TaskStore<S> for MetaAdapterTaskStore {
205	async fn add(&self, task: &TaskMeta<S>, key: Option<&str>) -> ClResult<TaskId> {
206		let id = self
207			.meta_adapter
208			.create_task(task.task.kind_of(), key, &task.task.serialize(), &task.deps)
209			.await?;
210
211		// Store cron schedule if present
212		if let Some(cron) = &task.cron {
213			self.meta_adapter
214				.update_task(
215					id,
216					&meta_adapter::TaskPatch {
217						cron: Patch::Value(cron.to_cron_string()),
218						..Default::default()
219					},
220				)
221				.await?;
222		}
223
224		Ok(id)
225	}
226
227	async fn finished(&self, id: TaskId, output: &str) -> ClResult<()> {
228		self.meta_adapter.update_task_finished(id, output).await
229	}
230
231	async fn load(&self) -> ClResult<Vec<TaskData>> {
232		let tasks = self.meta_adapter.list_tasks(meta_adapter::ListTaskOptions::default()).await?;
233		let tasks = tasks
234			.into_iter()
235			.map(|t| TaskData {
236				id: t.task_id,
237				kind: t.kind,
238				status: match t.status {
239					'P' => TaskStatus::Pending,
240					'F' => TaskStatus::Completed,
241					// 'E' or unknown status = Failed
242					_ => TaskStatus::Failed,
243				},
244				input: t.input,
245				deps: t.deps,
246				retry_data: t.retry,
247				cron_data: t.cron,
248				next_at: t.next_at,
249			})
250			.collect();
251		Ok(tasks)
252	}
253
254	async fn update_task_error(
255		&self,
256		task_id: TaskId,
257		output: &str,
258		next_at: Option<Timestamp>,
259	) -> ClResult<()> {
260		self.meta_adapter.update_task_error(task_id, output, next_at).await
261	}
262
263	async fn find_by_key(&self, key: &str) -> ClResult<Option<(TaskId, TaskData)>> {
264		let task_opt = self.meta_adapter.find_task_by_key(key).await?;
265
266		match task_opt {
267			Some(t) => Ok(Some((
268				t.task_id,
269				TaskData {
270					id: t.task_id,
271					kind: t.kind,
272					status: match t.status {
273						'P' => TaskStatus::Pending,
274						'F' => TaskStatus::Completed,
275						// 'E' or unknown status = Failed
276						_ => TaskStatus::Failed,
277					},
278					input: t.input,
279					deps: t.deps,
280					retry_data: t.retry,
281					cron_data: t.cron,
282					next_at: t.next_at,
283				},
284			))),
285			None => Ok(None),
286		}
287	}
288
289	async fn update_task(&self, id: TaskId, task: &TaskMeta<S>) -> ClResult<()> {
290		use cloudillo_types::types::Patch;
291
292		// Build TaskPatch from TaskMeta
293		let mut patch = meta_adapter::TaskPatch {
294			input: Patch::Value(task.task.serialize()),
295			next_at: match task.next_at {
296				Some(ts) => Patch::Value(ts),
297				None => Patch::Null,
298			},
299			..Default::default()
300		};
301
302		// Update deps
303		if !task.deps.is_empty() {
304			patch.deps = Patch::Value(task.deps.clone());
305		}
306
307		// Update retry policy
308		if let Some(ref retry) = task.retry {
309			let retry_str = format!(
310				"{},{},{},{}",
311				task.retry_count, retry.wait_min_max.0, retry.wait_min_max.1, retry.times
312			);
313			patch.retry = Patch::Value(retry_str);
314		}
315
316		// Update cron schedule
317		if let Some(ref cron) = task.cron {
318			patch.cron = Patch::Value(cron.to_cron_string());
319		}
320
321		self.meta_adapter.update_task(id, &patch).await
322	}
323
324	async fn find_completed_deps(&self, deps: &[TaskId]) -> ClResult<Vec<TaskId>> {
325		self.meta_adapter.find_completed_deps(deps).await
326	}
327}
328
329// Task metadata
330type TaskBuilder<S> = dyn Fn(TaskId, &str) -> ClResult<Arc<dyn Task<S>>> + Send + Sync;
331
332#[derive(Debug, Clone)]
333pub struct RetryPolicy {
334	wait_min_max: (u64, u64),
335	times: u16,
336}
337
338impl Default for RetryPolicy {
339	fn default() -> Self {
340		Self { wait_min_max: (60, 3600), times: 10 }
341	}
342}
343
344impl RetryPolicy {
345	/// Create a new RetryPolicy with custom min/max backoff and number of retries
346	pub fn new(wait_min_max: (u64, u64), times: u16) -> Self {
347		Self { wait_min_max, times }
348	}
349
350	/// Calculate exponential backoff in seconds: min * (2^attempt), capped at max
351	///
352	/// `times` is a `u16` and `new` is public, so a policy can ask for an attempt
353	/// count past the width of the shift. Saturating there rather than shifting
354	/// out is what keeps this panic-free in debug builds.
355	pub fn calculate_backoff(&self, attempt_count: u16) -> u64 {
356		let (min, max) = self.wait_min_max;
357		let backoff = 1u64
358			.checked_shl(u32::from(attempt_count))
359			.map_or(u64::MAX, |factor| min.saturating_mul(factor));
360		backoff.min(max)
361	}
362
363	/// Check if we should continue retrying
364	pub fn should_retry(&self, attempt_count: u16) -> bool {
365		attempt_count < self.times
366	}
367}
368
369// TaskSchedulerBuilder - Fluent API for task scheduling
370//************************************************************
371pub struct TaskSchedulerBuilder<'a, S: Clone> {
372	scheduler: &'a Scheduler<S>,
373	task: Arc<dyn Task<S>>,
374	key: Option<String>,
375	next_at: Option<Timestamp>,
376	deps: Vec<TaskId>,
377	retry: Option<RetryPolicy>,
378	cron: Option<CronSchedule>,
379	run_on_startup: bool,
380}
381
382impl<'a, S: Clone + Send + Sync + 'static> TaskSchedulerBuilder<'a, S> {
383	/// Create a new builder for scheduling a task
384	fn new(scheduler: &'a Scheduler<S>, task: Arc<dyn Task<S>>) -> Self {
385		Self {
386			scheduler,
387			task,
388			key: None,
389			next_at: None,
390			deps: Vec::new(),
391			retry: None,
392			cron: None,
393			run_on_startup: false,
394		}
395	}
396
397	/// Set a string key for task identification
398	pub fn key(mut self, key: impl Into<String>) -> Self {
399		self.key = Some(key.into());
400		self
401	}
402
403	/// Schedule for a specific absolute timestamp
404	pub fn schedule_at(mut self, timestamp: Timestamp) -> Self {
405		self.next_at = Some(timestamp);
406		self
407	}
408
409	/// Schedule after a relative delay (in seconds)
410	pub fn schedule_after(mut self, seconds: i64) -> Self {
411		self.next_at = Some(Timestamp::from_now(seconds));
412		self
413	}
414
415	/// Add task dependencies - task waits for all of these to complete
416	pub fn depend_on(mut self, deps: Vec<TaskId>) -> Self {
417		self.deps = deps;
418		self
419	}
420
421	/// Add a single task dependency
422	pub fn depends_on(mut self, dep: TaskId) -> Self {
423		self.deps.push(dep);
424		self
425	}
426
427	/// Enable automatic retry with exponential backoff
428	pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
429		self.retry = Some(policy);
430		self
431	}
432
433	// ===== Cron Scheduling Methods =====
434
435	/// Schedule task with cron expression
436	/// Example: `.cron("0 9 * * *")` for 9 AM daily
437	///
438	/// A bad expression is logged and ignored rather than propagated — the
439	/// builder is infallible by design. But ignoring it silently degrades a
440	/// recurring task into a one-shot: `add_queue` sees `next_at: None`, runs the
441	/// task once immediately, and the finish handler retires the row as completed,
442	/// so it never comes back. Expressions that reach here from a setting must
443	/// also be validated at write time (see `core_settings::cron_validator`); the
444	/// `error!` is what makes an already-stored bad value visible.
445	pub fn cron(mut self, expr: impl Into<String>) -> Self {
446		let expr = expr.into();
447		match CronSchedule::parse(&expr) {
448			Ok(cron_schedule) => {
449				self.next_at = cron_schedule.next_execution(Timestamp::now()).ok();
450				self.cron = Some(cron_schedule);
451			}
452			Err(e) => error!(
453				"scheduler: task '{}' has an unusable cron expression {:?} ({}); it will run \
454				 once instead of recurring",
455				self.task.kind_of(),
456				expr,
457				e
458			),
459		}
460		self
461	}
462
463	/// Schedule task daily at specified time
464	/// Example: `.daily_at(2, 30)` for 2:30 AM daily
465	pub fn daily_at(mut self, hour: u8, minute: u8) -> Self {
466		if hour <= 23 && minute <= 59 {
467			let expr = format!("{} {} * * *", minute, hour);
468			if let Ok(cron_schedule) = CronSchedule::parse(&expr) {
469				// Calculate initial next_at from cron schedule
470				// Use .ok() - cron was just parsed successfully, should never fail
471				self.next_at = cron_schedule.next_execution(Timestamp::now()).ok();
472				self.cron = Some(cron_schedule);
473			}
474		}
475		self
476	}
477
478	/// Schedule task weekly at specified day and time
479	/// Example: `.weekly_at(1, 14, 30)` for Mondays at 2:30 PM
480	/// weekday: 0=Sunday, 1=Monday, ..., 6=Saturday
481	pub fn weekly_at(mut self, weekday: u8, hour: u8, minute: u8) -> Self {
482		if weekday <= 6 && hour <= 23 && minute <= 59 {
483			let expr = format!("{} {} * * {}", minute, hour, weekday);
484			if let Ok(cron_schedule) = CronSchedule::parse(&expr) {
485				// Calculate initial next_at from cron schedule
486				// Use .ok() - cron was just parsed successfully, should never fail
487				self.next_at = cron_schedule.next_execution(Timestamp::now()).ok();
488				self.cron = Some(cron_schedule);
489			}
490		}
491		self
492	}
493
494	/// Opt-in: if this is a cron task and a scheduled run was missed
495	/// while the server was down (or this is the first time the task
496	/// is being registered), run it once immediately on startup before
497	/// resuming the normal cron schedule.
498	pub fn run_on_startup(mut self) -> Self {
499		self.run_on_startup = true;
500		self
501	}
502
503	/// Execute the scheduled task immediately
504	pub async fn now(self) -> ClResult<TaskId> {
505		self.schedule().await
506	}
507
508	/// Execute the scheduled task at a specific timestamp
509	pub async fn at(mut self, ts: Timestamp) -> ClResult<TaskId> {
510		self.next_at = Some(ts);
511		self.schedule().await
512	}
513
514	/// Execute the scheduled task after a delay (in seconds)
515	pub async fn after(mut self, seconds: i64) -> ClResult<TaskId> {
516		self.next_at = Some(Timestamp::from_now(seconds));
517		self.schedule().await
518	}
519
520	/// Execute the scheduled task after another task completes
521	pub async fn after_task(mut self, dep: TaskId) -> ClResult<TaskId> {
522		self.deps.push(dep);
523		self.schedule().await
524	}
525
526	/// Execute the scheduled task with automatic retry using default policy
527	pub async fn with_automatic_retry(mut self) -> ClResult<TaskId> {
528		self.retry = Some(RetryPolicy::default());
529		self.schedule().await
530	}
531
532	/// Execute the task with all configured options - main terminal method
533	pub async fn schedule(self) -> ClResult<TaskId> {
534		self.scheduler
535			.schedule_task_impl(
536				self.task,
537				self.key.as_deref(),
538				self.next_at,
539				if self.deps.is_empty() { None } else { Some(self.deps) },
540				self.retry,
541				self.cron,
542				self.run_on_startup,
543			)
544			.await
545	}
546}
547
548#[derive(Debug, Clone)]
549pub struct TaskMeta<S: Clone> {
550	pub task: Arc<dyn Task<S>>,
551	pub next_at: Option<Timestamp>,
552	pub deps: Vec<TaskId>,
553	retry_count: u16,
554	pub retry: Option<RetryPolicy>,
555	pub cron: Option<CronSchedule>,
556	/// Set while this task is running, by a keyed re-request that arrived in
557	/// flight.
558	///
559	/// The re-request cannot start a second body — that is what the in-place
560	/// metadata update in [`Scheduler::schedule_task_impl`] prevents — and, unlike
561	/// a cron task, a one-shot has no reschedule step that would pick the request
562	/// up. Without this the requested run is simply dropped, so the finish handler
563	/// consults it and re-queues instead of finishing the task.
564	///
565	/// It lives here rather than in a side table so that reading it, reading the
566	/// metadata the request wrote, and closing the request window are one
567	/// operation under one lock. **Invariant:** true only for an entry currently
568	/// in `tasks_running`.
569	rerun_requested: bool,
570}
571
572type TaskBuilderRegistry<S> = HashMap<&'static str, Box<TaskBuilder<S>>>;
573type ScheduledTaskMap<S> = BTreeMap<(Timestamp, TaskId), TaskMeta<S>>;
574
575// Scheduler
576#[derive(Clone)]
577pub struct Scheduler<S: Clone> {
578	task_builders: Arc<RwLock<TaskBuilderRegistry<S>>>,
579	store: Arc<dyn TaskStore<S>>,
580	tasks_running: Arc<Mutex<HashMap<TaskId, TaskMeta<S>>>>,
581	tasks_waiting: Arc<Mutex<HashMap<TaskId, TaskMeta<S>>>>,
582	task_dependents: Arc<Mutex<HashMap<TaskId, Vec<TaskId>>>>,
583	tasks_scheduled: Arc<Mutex<ScheduledTaskMap<S>>>,
584	tx_finish: flume::Sender<TaskId>,
585	rx_finish: flume::Receiver<TaskId>,
586	notify_schedule: Arc<tokio::sync::Notify>,
587}
588
589impl<S: Clone + Send + Sync + 'static> Scheduler<S> {
590	pub fn new(store: Arc<dyn TaskStore<S>>) -> Arc<Self> {
591		let (tx_finish, rx_finish) = flume::unbounded();
592
593		let scheduler = Self {
594			task_builders: Arc::new(RwLock::new(HashMap::new())),
595			store,
596			tasks_running: Arc::new(Mutex::new(HashMap::new())),
597			tasks_waiting: Arc::new(Mutex::new(HashMap::new())),
598			task_dependents: Arc::new(Mutex::new(HashMap::new())),
599			tasks_scheduled: Arc::new(Mutex::new(BTreeMap::new())),
600			tx_finish,
601			rx_finish,
602			notify_schedule: Arc::new(tokio::sync::Notify::new()),
603		};
604
605		//scheduler.run(rx_finish)?;
606
607		Arc::new(scheduler)
608	}
609
610	pub fn start(&self, state: S) {
611		// Handle finished tasks and dependencies
612		let schedule = self.clone();
613		let stat = state.clone();
614		let rx_finish = self.rx_finish.clone();
615
616		tokio::spawn(async move {
617			while let Ok(id) = rx_finish.recv_async().await {
618				debug!("Completed task {} (notified)", id);
619
620				// Taken out of `tasks_running` up front, then decided. Safe because by
621				// the time this event arrives the task body has *already returned* —
622				// removing the id cannot let a second body start beside a running one,
623				// which is what the old transition bookkeeping existed to police. It is
624				// also what closes the re-request window atomically: once the id is out
625				// of the map, `schedule_task_impl`'s in-place update misses and the
626				// request queues itself through the ordinary path, so no request can be
627				// recorded that this handler will not see.
628				let Some(task_meta) = schedule.take_running(id) else {
629					warn!("Completed task {} not found in running queue", id);
630					continue;
631				};
632
633				// Before the cron test on purpose: a cron task re-requested as a
634				// one-shot runs now, and since `rerun_meta` keeps the `cron` field the
635				// run after it resumes recurring.
636				if task_meta.rerun_requested {
637					// A keyed task re-requested while this run was in flight was only
638					// updated in place — `schedule_task_impl` deliberately does not start
639					// a second body next to the running one. This is where the requested
640					// run happens, and `task_meta` *is* whatever that update wrote.
641					info!("Task {} was re-requested while running; running again", id);
642					let mut rerun_meta = task_meta;
643					rerun_meta.next_at = None;
644					rerun_meta.retry_count = 0;
645					rerun_meta.rerun_requested = false;
646					if let Err(e) = schedule.add_queue(id, rerun_meta).await {
647						error!(
648							"Failed to re-queue task {} after in-flight update: {} - task lost!",
649							id, e
650						);
651					}
652				} else if let Some(cron) = &task_meta.cron {
653					match cron.next_execution(Timestamp::now()) {
654						Ok(next_at) => {
655							info!(
656								"Recurring task {} completed, scheduling next execution at {}",
657								id, next_at
658							);
659							let mut updated_meta = task_meta.clone();
660							updated_meta.next_at = Some(next_at);
661							// Persist the new `next_at` and keep the row `'P'`.
662							if let Err(e) = schedule.store.update_task(id, &updated_meta).await {
663								error!("Failed to update recurring task {} next_at: {}", id, e);
664							}
665							if let Err(e) = schedule.add_queue(id, updated_meta).await {
666								error!(
667									"Failed to reschedule recurring task {}: {} - task lost!",
668									id, e
669								);
670							}
671						}
672						Err(e) => {
673							error!(
674								"Failed to calculate next execution for recurring task {}: {} - task will not reschedule",
675								id, e
676							);
677							// Cannot reschedule, so finish it. Falls through to the
678							// dependents release rather than `continue`-ing past it: the run
679							// did complete, so its dependents are owed their release.
680							if let Err(e) = schedule.store.finished(id, "").await {
681								error!("Failed to mark task {} as finished: {}", id, e);
682							}
683						}
684					}
685				} else if let Err(e) = schedule.store.finished(id, "").await {
686					// Removal from `tasks_running` already happened, so a failure here
687					// strands nothing in memory. Recovery is the persisted row, which
688					// stays `status='P'` and is re-queued by `load()` at next start.
689					//
690					// Known open race: a request landing after the take but during this
691					// await queues itself through the ordinary path, and `mark_finished`
692					// then stamps the row `'F'` underneath it — so a further re-request
693					// for that key mints a *new* id and row, a crash during the rerun
694					// loses it, and the rerun's own `finished` is a silent no-op. Closing
695					// it needs a store-level re-open (a patch back to `status='P'`, or
696					// making `finished` conditional on no successor).
697					error!("Failed to mark task {} as finished: {}", id, e);
698				}
699
700				// Handle dependencies of finished task using atomic release method
701				match schedule.release_dependents(id) {
702					Ok(ready_to_spawn) => {
703						for (dep_id, dep_task_meta) in ready_to_spawn {
704							// Add to running queue before spawning
705							match schedule.tasks_running.lock() {
706								Ok(mut tasks_running) => {
707									tasks_running.insert(dep_id, dep_task_meta.clone());
708								}
709								Err(poisoned) => {
710									error!("Mutex poisoned: tasks_running (recovering)");
711									poisoned.into_inner().insert(dep_id, dep_task_meta.clone());
712								}
713							}
714							schedule.spawn_task(
715								stat.clone(),
716								dep_task_meta.task.clone(),
717								dep_id,
718								dep_task_meta,
719							);
720						}
721					}
722					Err(e) => {
723						error!("Failed to release dependents of task {}: {}", id, e);
724					}
725				}
726			}
727		});
728
729		// Handle scheduled tasks
730		let schedule = self.clone();
731		tokio::spawn(async move {
732			loop {
733				let is_empty = match schedule.tasks_scheduled.lock() {
734					Ok(guard) => guard.is_empty(),
735					Err(poisoned) => {
736						error!("Mutex poisoned: tasks_scheduled (recovering)");
737						poisoned.into_inner().is_empty()
738					}
739				};
740				if is_empty {
741					schedule.notify_schedule.notified().await;
742				}
743				let time = Timestamp::now();
744				if let Some((timestamp, _id)) = loop {
745					let mut tasks_scheduled = match schedule.tasks_scheduled.lock() {
746						Ok(guard) => guard,
747						Err(poisoned) => {
748							error!("Mutex poisoned: tasks_scheduled (recovering)");
749							poisoned.into_inner()
750						}
751					};
752					if let Some((&(timestamp, id), _)) = tasks_scheduled.first_key_value() {
753						let (timestamp, id) = (timestamp, id);
754						if timestamp <= Timestamp::now() {
755							debug!("Spawning task id {} (from schedule)", id);
756							if let Some(task) = tasks_scheduled.remove(&(timestamp, id)) {
757								let mut tasks_running = match schedule.tasks_running.lock() {
758									Ok(guard) => guard,
759									Err(poisoned) => {
760										error!("Mutex poisoned: tasks_running (recovering)");
761										poisoned.into_inner()
762									}
763								};
764								tasks_running.insert(id, task.clone());
765								schedule.spawn_task(state.clone(), task.task.clone(), id, task);
766							} else {
767								error!("Task disappeared while being removed from schedule");
768								break None;
769							}
770						} else {
771							break Some((timestamp, id));
772						}
773					} else {
774						break None;
775					}
776				} {
777					let diff = timestamp.0 - time.0;
778					let wait =
779						tokio::time::Duration::from_secs(u64::try_from(diff).unwrap_or_default());
780					tokio::select! {
781						() = tokio::time::sleep(wait) => (), () = schedule.notify_schedule.notified() => ()
782					};
783				}
784			}
785		});
786
787		let schedule = self.clone();
788		tokio::spawn(async move {
789			// Only fatal failures reach here — `load` contains per-row ones itself.
790			// A scheduler that loaded nothing must say so rather than start empty
791			// and look healthy.
792			if let Err(e) = schedule.load().await {
793				error!("scheduler: failed to load persisted tasks: {}", e);
794			}
795		});
796	}
797
798	fn register_builder(
799		&self,
800		name: &'static str,
801		builder: &'static TaskBuilder<S>,
802	) -> ClResult<&Self> {
803		let mut task_builders = self
804			.task_builders
805			.write()
806			.map_err(|_| Error::Internal("task_builders RwLock poisoned".into()))?;
807		task_builders.insert(name, Box::new(builder));
808		Ok(self)
809	}
810
811	pub fn register<T: Task<S>>(&self) -> ClResult<&Self> {
812		info!("Registering task type {}", T::kind());
813		self.register_builder(T::kind(), &|id: TaskId, params: &str| T::build(id, params))?;
814		Ok(self)
815	}
816
817	/// Create a builder for scheduling a task using the fluent API
818	pub fn task(&self, task: Arc<dyn Task<S>>) -> TaskSchedulerBuilder<'_, S> {
819		TaskSchedulerBuilder::new(self, task)
820	}
821
822	/// Internal method to schedule a task with all options
823	/// This is the core implementation used by the builder pattern
824	#[allow(clippy::too_many_arguments)]
825	async fn schedule_task_impl(
826		&self,
827		task: Arc<dyn Task<S>>,
828		key: Option<&str>,
829		next_at: Option<Timestamp>,
830		deps: Option<Vec<TaskId>>,
831		retry: Option<RetryPolicy>,
832		cron: Option<CronSchedule>,
833		run_on_startup: bool,
834	) -> ClResult<TaskId> {
835		// Look up any existing task by key once; reuse for both the
836		// run_on_startup decision and the dedup branch below.
837		let existing = if let Some(k) = key { self.store.find_by_key(k).await? } else { None };
838
839		// Resolve effective next_at, factoring in run_on_startup for cron tasks.
840		let effective_next_at = if run_on_startup && cron.is_some() {
841			match &existing {
842				Some((_existing_id, existing_data)) => {
843					// Task exists from a previous run. If its persisted
844					// next_at has already passed (or is missing), we missed
845					// a run while down — fire now. Otherwise honor the
846					// persisted future schedule.
847					match existing_data.next_at {
848						Some(persisted) if persisted > Timestamp::now() => next_at,
849						_ => Some(Timestamp::now()),
850					}
851				}
852				None => Some(Timestamp::now()), // fresh registration → run now
853			}
854		} else {
855			next_at
856		};
857
858		let task_meta = TaskMeta {
859			task: task.clone(),
860			next_at: effective_next_at,
861			deps: deps.clone().unwrap_or_default(),
862			retry_count: 0,
863			retry,
864			cron,
865			rerun_requested: false,
866		};
867
868		// Check if a task with this key already exists (key-based deduplication)
869		if let Some(key) = key
870			&& let Some((existing_id, existing_data)) = existing
871		{
872			let new_serialized = task.serialize();
873			let existing_serialized = existing_data.input.as_ref();
874			let params_changed = new_serialized != existing_serialized;
875
876			if params_changed {
877				info!(
878					"Updating recurring task '{}' (id={}) - parameters changed",
879					key, existing_id
880				);
881				debug!("  Old params: {}", existing_serialized);
882				debug!("  New params: {}", new_serialized);
883			} else {
884				info!(
885					"Recurring task '{}' already exists with identical parameters (id={})",
886					key, existing_id
887				);
888			}
889
890			// The running check comes before the identical/changed split, not inside
891			// the changed branch: a re-request with *identical* parameters is the
892			// common shape — `IndexDocumentTask { tn_id, file_id }` serializes the
893			// same way on every edit of the same file — and carries exactly as much
894			// "run it again" intent as a changed one.
895			//
896			// A *running* task is updated in place and left alone. Going through
897			// `remove_from_queues` first would take it out of `tasks_running`, the
898			// very map `add_queue`'s already-running check consults, so the check
899			// would miss and a second body would start alongside the one still
900			// executing — for `core.db_maintenance:manual`, two concurrent VACUUMs
901			// and two `tx_finish` events for one task id.
902			//
903			// The run in flight then reschedules itself from the new parameters —
904			// but only if it is a *cron* task. A keyed one-shot takes the
905			// `store.finished` branch instead, so the requested run would never
906			// happen; those mark the running entry `rerun_requested` and let the
907			// finish handler re-queue.
908			//
909			// The guard is confined to this block: it is not `Send`, so holding it
910			// across the `await` below would make the whole handler non-`Send`.
911			let was_running = {
912				let mut running = lock!(self.tasks_running, "tasks_running")?;
913				match running.get_mut(&existing_id) {
914					Some(existing_meta) => {
915						debug!("Task {} is running; updating metadata in place", existing_id);
916						let rerun = existing_meta.rerun_requested || task_meta.cron.is_none();
917						*existing_meta = task_meta.clone();
918						existing_meta.rerun_requested = rerun;
919						true
920					}
921					None => false,
922				}
923			};
924			if was_running {
925				self.store.update_task(existing_id, &task_meta).await?;
926				return Ok(existing_id);
927			}
928
929			if params_changed {
930				self.remove_from_queues(existing_id)?;
931			}
932
933			// Update the task in database with the current parameters, cron and
934			// next_at (any of which may differ from what is stored).
935			self.store.update_task(existing_id, &task_meta).await?;
936
937			// Ensure the task is queued — it may be loaded from the DB but not yet
938			// in a queue — with the updated parameters.
939			self.add_queue(existing_id, task_meta).await?;
940
941			return Ok(existing_id);
942		}
943
944		// No existing task - create new one
945		let id = self.store.add(&task_meta, key).await?;
946		self.add_queue(id, task_meta).await
947	}
948
949	pub async fn add(&self, task: Arc<dyn Task<S>>) -> ClResult<TaskId> {
950		self.task(task).now().await
951	}
952
953	pub async fn add_queue(&self, id: TaskId, task_meta: TaskMeta<S>) -> ClResult<TaskId> {
954		debug_assert!(
955			!task_meta.rerun_requested,
956			"a queued task must not carry a pending rerun request"
957		);
958		// If task is already running, update its metadata (especially for cron updates)
959		// but don't add to scheduled queue (it will reschedule on completion)
960		{
961			let mut running = lock!(self.tasks_running, "tasks_running")?;
962			if let Some(existing_meta) = running.get_mut(&id) {
963				debug!(
964					"Task {} is already running, updating metadata (will reschedule on completion)",
965					id
966				);
967				// Update the running task's metadata so it has the latest cron schedule.
968				// The rerun flag records a request that has not run yet, so a metadata
969				// refresh must carry it across rather than drop it.
970				let rerun = existing_meta.rerun_requested;
971				*existing_meta = task_meta;
972				existing_meta.rerun_requested = rerun;
973				return Ok(id);
974			}
975		}
976
977		// Remove from other queues if present (prevents duplicate entries with different timestamps)
978		{
979			let mut scheduled = lock!(self.tasks_scheduled, "tasks_scheduled")?;
980			if let Some(key) = scheduled
981				.iter()
982				.find(|((_, tid), _)| *tid == id)
983				.map(|((ts, tid), _)| (*ts, *tid))
984			{
985				scheduled.remove(&key);
986				debug!("Removed existing scheduled entry for task {} before re-queueing", id);
987			}
988		}
989		{
990			let mut waiting = lock!(self.tasks_waiting, "tasks_waiting")?;
991			if waiting.remove(&id).is_some() {
992				debug!("Removed existing waiting entry for task {} before re-queueing", id);
993			}
994		}
995
996		let deps = task_meta.deps.clone();
997
998		// VALIDATION: Tasks with dependencies should NEVER be in tasks_scheduled
999		if !deps.is_empty() && task_meta.next_at.is_some() {
1000			warn!(
1001				"Task {} has both dependencies and scheduled time - ignoring next_at, placing in waiting queue",
1002				id
1003			);
1004			// Force to tasks_waiting instead
1005			lock!(self.tasks_waiting, "tasks_waiting")?.insert(id, task_meta);
1006			debug!("Task {} is waiting for {:?}", id, &deps);
1007			for dep in &deps {
1008				lock!(self.task_dependents, "task_dependents")?
1009					.entry(*dep)
1010					.or_default()
1011					.push(id);
1012			}
1013
1014			self.check_and_resolve_completed_deps(id, &deps).await?;
1015			return Ok(id);
1016		}
1017
1018		if deps.is_empty() && task_meta.next_at.unwrap_or(Timestamp(0)) < Timestamp::now() {
1019			debug!("Spawning task {}", id);
1020			lock!(self.tasks_scheduled, "tasks_scheduled")?.insert((Timestamp(0), id), task_meta);
1021			self.notify_schedule.notify_one();
1022		} else if let Some(next_at) = task_meta.next_at {
1023			debug!("Scheduling task {} for {}", id, next_at);
1024			lock!(self.tasks_scheduled, "tasks_scheduled")?.insert((next_at, id), task_meta);
1025			self.notify_schedule.notify_one();
1026		} else {
1027			lock!(self.tasks_waiting, "tasks_waiting")?.insert(id, task_meta);
1028			debug!("Task {} is waiting for {:?}", id, &deps);
1029			for dep in &deps {
1030				lock!(self.task_dependents, "task_dependents")?
1031					.entry(*dep)
1032					.or_default()
1033					.push(id);
1034			}
1035
1036			self.check_and_resolve_completed_deps(id, &deps).await?;
1037		}
1038		Ok(id)
1039	}
1040
1041	/// After registering deps, check if any completed in the meantime.
1042	/// If all deps are satisfied, move the task from waiting → scheduled.
1043	async fn check_and_resolve_completed_deps(&self, id: TaskId, deps: &[TaskId]) -> ClResult<()> {
1044		let completed_deps = self.store.find_completed_deps(deps).await?;
1045		if completed_deps.is_empty() {
1046			return Ok(());
1047		}
1048		let mut waiting = lock!(self.tasks_waiting, "tasks_waiting")?;
1049		if let Some(task_meta) = waiting.get_mut(&id) {
1050			for dep in &completed_deps {
1051				task_meta.deps.retain(|d| *d != *dep);
1052			}
1053			if task_meta.deps.is_empty()
1054				&& let Some(ready_task) = waiting.remove(&id)
1055			{
1056				drop(waiting);
1057				let mut dependents = lock!(self.task_dependents, "task_dependents")?;
1058				for dep in deps {
1059					if let Some(dep_list) = dependents.get_mut(dep) {
1060						dep_list.retain(|d| *d != id);
1061						if dep_list.is_empty() {
1062							dependents.remove(dep);
1063						}
1064					}
1065				}
1066				drop(dependents);
1067				debug!("Task {} deps already completed, scheduling immediately", id);
1068				lock!(self.tasks_scheduled, "tasks_scheduled")?
1069					.insert((Timestamp(0), id), ready_task);
1070				self.notify_schedule.notify_one();
1071			}
1072		}
1073		Ok(())
1074	}
1075
1076	/// Remove a task from all internal queues (waiting, scheduled, running)
1077	/// Returns the removed TaskMeta if found
1078	fn remove_from_queues(&self, task_id: TaskId) -> ClResult<Option<TaskMeta<S>>> {
1079		// Try tasks_waiting
1080		if let Some(task_meta) = lock!(self.tasks_waiting, "tasks_waiting")?.remove(&task_id) {
1081			debug!("Removed task {} from waiting queue for update", task_id);
1082			return Ok(Some(task_meta));
1083		}
1084
1085		// Try tasks_scheduled (need to find by task_id in BTreeMap)
1086		{
1087			let mut scheduled = lock!(self.tasks_scheduled, "tasks_scheduled")?;
1088			if let Some(key) = scheduled
1089				.iter()
1090				.find(|((_, id), _)| *id == task_id)
1091				.map(|((ts, id), _)| (*ts, *id))
1092				&& let Some(task_meta) = scheduled.remove(&key)
1093			{
1094				debug!("Removed task {} from scheduled queue for update", task_id);
1095				return Ok(Some(task_meta));
1096			}
1097		}
1098
1099		// Try tasks_running (should rarely happen, but handle it)
1100		if let Some(task_meta) = lock!(self.tasks_running, "tasks_running")?.remove(&task_id) {
1101			warn!("Removed task {} from running queue during update", task_id);
1102			return Ok(Some(task_meta));
1103		}
1104
1105		Ok(None)
1106	}
1107
1108	/// Release all dependent tasks of a completed task
1109	/// This method safely handles dependency cleanup and spawning
1110	fn release_dependents(
1111		&self,
1112		completed_task_id: TaskId,
1113	) -> ClResult<Vec<(TaskId, TaskMeta<S>)>> {
1114		// Get list of dependents (atomic removal to prevent re-processing)
1115		let dependents = {
1116			let mut deps_map = lock!(self.task_dependents, "task_dependents")?;
1117			deps_map.remove(&completed_task_id).unwrap_or_default()
1118		};
1119
1120		if dependents.is_empty() {
1121			return Ok(Vec::new()); // No dependents to release
1122		}
1123
1124		debug!("Releasing {} dependents of completed task {}", dependents.len(), completed_task_id);
1125
1126		let mut ready_to_spawn = Vec::new();
1127
1128		// For each dependent, check and remove dependency
1129		for dependent_id in dependents {
1130			// Try tasks_waiting first (most common case for dependent tasks)
1131			{
1132				let mut waiting = lock!(self.tasks_waiting, "tasks_waiting")?;
1133				if let Some(task_meta) = waiting.get_mut(&dependent_id) {
1134					// Remove the completed task from dependencies
1135					task_meta.deps.retain(|x| *x != completed_task_id);
1136
1137					// If all dependencies are cleared, remove and queue for spawning
1138					if task_meta.deps.is_empty() {
1139						if let Some(task_to_spawn) = waiting.remove(&dependent_id) {
1140							debug!(
1141								"Dependent task {} ready to spawn (all dependencies cleared)",
1142								dependent_id
1143							);
1144							ready_to_spawn.push((dependent_id, task_to_spawn));
1145						}
1146					} else {
1147						debug!(
1148							"Task {} still has {} remaining dependencies",
1149							dependent_id,
1150							task_meta.deps.len()
1151						);
1152					}
1153					continue;
1154				}
1155			}
1156
1157			// Try tasks_scheduled if not in waiting (shouldn't happen with validation, but be defensive)
1158			{
1159				let mut scheduled = lock!(self.tasks_scheduled, "tasks_scheduled")?;
1160				if let Some(scheduled_key) = scheduled
1161					.iter()
1162					.find(|((_, id), _)| *id == dependent_id)
1163					.map(|((ts, id), _)| (*ts, *id))
1164				{
1165					if let Some(task_meta) = scheduled.get_mut(&scheduled_key) {
1166						task_meta.deps.retain(|x| *x != completed_task_id);
1167						let remaining = task_meta.deps.len();
1168						if remaining == 0 {
1169							debug!(
1170								"Task {} in scheduled queue has no remaining dependencies",
1171								dependent_id
1172							);
1173						} else {
1174							debug!(
1175								"Task {} in scheduled queue has {} remaining dependencies",
1176								dependent_id, remaining
1177							);
1178						}
1179					}
1180					continue;
1181				}
1182			}
1183
1184			// Task not found in any queue
1185			warn!(
1186				"Dependent task {} of completed task {} not found in any queue",
1187				dependent_id, completed_task_id
1188			);
1189		}
1190
1191		Ok(ready_to_spawn)
1192	}
1193
1194	/// Re-queue every pending task the store holds.
1195	///
1196	/// Per-row failures are contained: an unregistered task kind, a malformed
1197	/// `retry` column or a rejected `add_queue` costs exactly that one row. The
1198	/// store's ordering is not partitioned by kind, so propagating out of the loop
1199	/// would let one bad row — a task kind an older build persisted and this one
1200	/// does not register — discard every ACME renewal, action fanout and email
1201	/// task behind it.
1202	///
1203	/// Only genuinely fatal failures propagate: the store read itself, and a
1204	/// poisoned `task_builders` lock.
1205	async fn load(&self) -> ClResult<()> {
1206		let tasks = self.store.load().await?;
1207		debug!("Loaded {} tasks from store", tasks.len());
1208		let (mut seen, mut queued, mut skipped) = (0usize, 0usize, 0usize);
1209		for t in tasks {
1210			if !matches!(t.status, TaskStatus::Pending) {
1211				continue;
1212			}
1213			seen += 1;
1214			let (id, kind) = (t.id, t.kind.clone());
1215			match self.load_one(t).await {
1216				Ok(()) => queued += 1,
1217				Err(e) => {
1218					skipped += 1;
1219					error!("scheduler: skipping persisted task {} ({}): {}", id, kind, e);
1220				}
1221			}
1222		}
1223		info!("scheduler: loaded {} pending tasks, {} queued, {} skipped", seen, queued, skipped);
1224		Ok(())
1225	}
1226
1227	/// Re-queue one persisted task. Every failure here is per-row — see [`Self::load`].
1228	async fn load_one(&self, t: TaskData) -> ClResult<()> {
1229		debug!("Loading task {} {}", t.id, t.kind);
1230		let task = {
1231			let builder_map = self
1232				.task_builders
1233				.read()
1234				.map_err(|_| Error::Internal("task_builders RwLock poisoned".into()))?;
1235			let builder = builder_map
1236				.get(t.kind.as_ref())
1237				.ok_or(Error::Internal(format!("task builder not registered: {}", t.kind)))?;
1238			builder(t.id, &t.input)?
1239		};
1240		let (retry_count, retry) = match t.retry_data {
1241			Some(retry_str) => {
1242				let (retry_count, retry_min, retry_max, retry_times) = retry_str
1243					.split(',')
1244					.collect_tuple()
1245					.ok_or(Error::Internal("invalid retry policy format".into()))?;
1246				let retry_count: u16 = retry_count
1247					.parse()
1248					.map_err(|_| Error::Internal("retry count must be u16".into()))?;
1249				let retry = RetryPolicy {
1250					wait_min_max: (
1251						retry_min
1252							.parse()
1253							.map_err(|_| Error::Internal("retry_min must be u64".into()))?,
1254						retry_max
1255							.parse()
1256							.map_err(|_| Error::Internal("retry_max must be u64".into()))?,
1257					),
1258					times: retry_times
1259						.parse()
1260						.map_err(|_| Error::Internal("retry times must be u64".into()))?,
1261				};
1262				debug!("Loaded retry policy: {:?}", retry);
1263				(retry_count, Some(retry))
1264			}
1265			_ => (0, None),
1266		};
1267		// Parse cron data if present. A persisted expression that no longer parses
1268		// is logged rather than dropped silently: the task keeps its `next_at` but
1269		// stops recurring, which is otherwise invisible.
1270		let cron = match t.cron_data.as_deref() {
1271			Some(cron_str) => match CronSchedule::parse(cron_str) {
1272				Ok(cron) => Some(cron),
1273				Err(e) => {
1274					error!(
1275						"scheduler: persisted task {} ({}) has an unusable cron expression \
1276						 {:?} ({}); it will not recur",
1277						t.id, t.kind, cron_str, e
1278					);
1279					None
1280				}
1281			},
1282			None => None,
1283		};
1284
1285		let task_meta = TaskMeta {
1286			task,
1287			next_at: t.next_at,
1288			deps: t.deps.into(),
1289			retry_count,
1290			retry,
1291			cron,
1292			rerun_requested: false,
1293		};
1294		self.add_queue(t.id, task_meta).await.map(|_| ())
1295	}
1296
1297	/// Take `id`'s entry out of `tasks_running`, metadata and rerun flag together.
1298	///
1299	/// Atomicity is the point. [`Self::schedule_task_impl`] records a rerun
1300	/// request by writing into this very entry, under this very lock, and only
1301	/// while the id is in the map — so removing the entry *is* the act of closing
1302	/// the request window, and whatever came with it is what the caller must
1303	/// honour.
1304	///
1305	/// Recovers from a poisoned lock rather than propagating: the finish handler
1306	/// has no error channel, and dropping the event strands the task.
1307	fn take_running(&self, id: TaskId) -> Option<TaskMeta<S>> {
1308		match self.tasks_running.lock() {
1309			Ok(mut running) => running.remove(&id),
1310			Err(poisoned) => {
1311				error!("Mutex poisoned: tasks_running (recovering)");
1312				poisoned.into_inner().remove(&id)
1313			}
1314		}
1315	}
1316
1317	/// Drop any in-flight "re-run when this finishes" request for `id`.
1318	///
1319	/// Both terminal paths in [`Self::spawn_task`] go through here, so they cannot
1320	/// drift apart again. The retry path does not: its entry is already out of the
1321	/// map, so it clears the flag on the metadata it carries instead. Recovers
1322	/// from a poisoned lock rather than propagating: a stale flag left set re-runs
1323	/// a task that must not run again.
1324	fn clear_rerun_request(&self, id: TaskId) {
1325		let mut running = match self.tasks_running.lock() {
1326			Ok(guard) => guard,
1327			Err(poisoned) => {
1328				error!("Mutex poisoned: tasks_running (recovering)");
1329				poisoned.into_inner()
1330			}
1331		};
1332		if let Some(meta) = running.get_mut(&id) {
1333			meta.rerun_requested = false;
1334		}
1335	}
1336
1337	fn spawn_task(&self, state: S, task: Arc<dyn Task<S>>, id: TaskId, task_meta: TaskMeta<S>) {
1338		let tx_finish = self.tx_finish.clone();
1339		let store = self.store.clone();
1340		let scheduler = self.clone();
1341		//let state = self.state.clone();
1342		tokio::spawn(async move {
1343			match task.run(&state).await {
1344				Ok(()) => {
1345					debug!("Task {} completed successfully", id);
1346					tx_finish.send(id).unwrap_or(());
1347				}
1348				Err(e) => {
1349					let is_retryable = e.is_retryable();
1350					if let Some(retry_policy) = &task_meta.retry {
1351						if is_retryable && retry_policy.should_retry(task_meta.retry_count) {
1352							let backoff = retry_policy.calculate_backoff(task_meta.retry_count);
1353							let next_at = Timestamp::from_now(backoff.cast_signed());
1354
1355							info!(
1356								"Task {} failed (attempt {}/{}). Scheduling retry in {} seconds: {}",
1357								id,
1358								task_meta.retry_count + 1,
1359								retry_policy.times,
1360								backoff,
1361								e
1362							);
1363
1364							// Update database with error and reschedule
1365							if let Err(err) =
1366								store.update_task_error(id, &e.to_string(), Some(next_at)).await
1367							{
1368								error!(
1369									"Failed to persist error for task {}: {} - retry not durable",
1370									id, err
1371								);
1372							}
1373
1374							task.on_attempt_failed(&state, task_meta.retry_count, &e.to_string())
1375								.await;
1376
1377							// Remove from running tasks (we're not sending finish
1378							// event), and take the *current* metadata out with it:
1379							// `task_meta` is the snapshot captured when this run was
1380							// spawned, and a keyed re-request may have updated the
1381							// task in place since. Retrying from the snapshot would
1382							// run the old parameters while the persisted row holds
1383							// the new ones. Falls back to the snapshot only if the
1384							// entry is already gone. The removal also recovers any
1385							// pending rerun request along with the metadata, and the
1386							// retry satisfies it — it carries the new parameters.
1387							let current_meta = match scheduler.tasks_running.lock() {
1388								Ok(mut tasks_running) => tasks_running.remove(&id),
1389								Err(poisoned) => {
1390									error!("Mutex poisoned: tasks_running (recovering)");
1391									poisoned.into_inner().remove(&id)
1392								}
1393							};
1394
1395							// Re-queue task with incremented retry count. Counted off
1396							// the snapshot, not off `current_meta`: an in-place update
1397							// carries `retry_count: 0`, and taking that as the base
1398							// would hand the task a fresh retry budget on every
1399							// re-request.
1400							let mut retry_meta = current_meta.unwrap_or_else(|| task_meta.clone());
1401							retry_meta.retry_count = task_meta.retry_count + 1;
1402							retry_meta.next_at = Some(next_at);
1403							// The retry carries the new parameters, so an in-flight
1404							// re-request is already satisfied — left set, the flag would
1405							// run the task a second time once the retry ends. Cleared on
1406							// the metadata rather than through
1407							// `Scheduler::clear_rerun_request`: the entry is already out
1408							// of the map above, so a map-clearing call would be a no-op.
1409							retry_meta.rerun_requested = false;
1410
1411							if let Err(err) = scheduler.add_queue(id, retry_meta).await {
1412								error!(
1413									"Failed to queue retry for task {}: {} - task lost!",
1414									id, err
1415								);
1416							}
1417						} else {
1418							// Max retries exhausted OR error is permanent
1419							if is_retryable {
1420								error!(
1421									"Task {} failed after {} retries: {}",
1422									id, task_meta.retry_count, e
1423								);
1424							} else {
1425								error!("Task {} failed permanently (non-retryable): {}", id, e);
1426							}
1427							// A cron task is not finished when a run fails — the finish
1428							// handler below reschedules it, and the row must stay `'P'`
1429							// or `find_by_key` and `load()` will lose it and mint a
1430							// duplicate on the next boot, re-firing `run_on_startup`.
1431							// A one-shot keeps `next_at: None` and so keeps `'E'`: a
1432							// fresh request there should mint a fresh row.
1433							let next_at = task_meta
1434								.cron
1435								.as_ref()
1436								.and_then(|c| c.next_execution(Timestamp::now()).ok());
1437							if let Err(err) =
1438								store.update_task_error(id, &e.to_string(), next_at).await
1439							{
1440								error!(
1441									"Failed to persist error for task {}: {} - retry not durable",
1442									id, err
1443								);
1444							}
1445							task.on_failed(&state, task_meta.retry_count, &e.to_string()).await;
1446							// Cleared for a different reason than in the retry branch
1447							// above, and the difference matters: `on_failed` has just
1448							// run its irreversible cleanup — `ActionVerifierTask` marks
1449							// the action `'F'`, "failed after retry exhaustion". Left
1450							// set, the finish handler would re-queue the task with
1451							// `retry_count: 0` against a row already declared
1452							// permanently failed, and every re-request landing during a
1453							// final attempt would renew the whole retry budget. A caller
1454							// that still wants the work done schedules a fresh task.
1455							scheduler.clear_rerun_request(id);
1456							tx_finish.send(id).unwrap_or(());
1457						}
1458					} else {
1459						// No retry policy - fail immediately. Terminal in the same
1460						// sense as the exhausted branch above, so the same reasoning
1461						// applies to the rerun flag.
1462						error!("Task {} failed: {}", id, e);
1463						// Same as the exhausted branch above: a cron task's row must
1464						// stay `'P'` with a live `next_at`, or the next boot cannot
1465						// find it and mints a duplicate.
1466						let next_at = task_meta
1467							.cron
1468							.as_ref()
1469							.and_then(|c| c.next_execution(Timestamp::now()).ok());
1470						if let Err(err) = store.update_task_error(id, &e.to_string(), next_at).await
1471						{
1472							error!(
1473								"Failed to persist error for task {}: {} - retry not durable",
1474								id, err
1475							);
1476						}
1477						task.on_failed(&state, 0, &e.to_string()).await;
1478						scheduler.clear_rerun_request(id);
1479						tx_finish.send(id).unwrap_or(());
1480					}
1481				}
1482			}
1483		});
1484	}
1485
1486	/// Get health status of the scheduler
1487	/// Returns information about tasks in each queue and detects anomalies
1488	pub async fn health_check(&self) -> ClResult<SchedulerHealth> {
1489		let waiting_count = lock!(self.tasks_waiting, "tasks_waiting")?.len();
1490		let scheduled_count = lock!(self.tasks_scheduled, "tasks_scheduled")?.len();
1491		let running_count = lock!(self.tasks_running, "tasks_running")?.len();
1492		let dependents_count = lock!(self.task_dependents, "task_dependents")?.len();
1493
1494		// Check for anomalies
1495		let mut stuck_tasks = Vec::new();
1496		let mut tasks_with_missing_deps = Vec::new();
1497
1498		// Check tasks_waiting for tasks with no dependencies (stuck)
1499		{
1500			let waiting = lock!(self.tasks_waiting, "tasks_waiting")?;
1501			let _deps_map = lock!(self.task_dependents, "task_dependents")?;
1502
1503			for (id, task_meta) in waiting.iter() {
1504				if task_meta.deps.is_empty() {
1505					stuck_tasks.push(*id);
1506					warn!("SCHEDULER HEALTH: Task {} in waiting with no dependencies", id);
1507				} else {
1508					// Check if all dependencies still exist. Warning-only, and it can
1509					// now fire for a dep that is merely mid-finish-handling: the finish
1510					// handler takes the entry out of `tasks_running` before awaiting
1511					// `store.finished`, so the window this probe can land in spans that
1512					// await instead of ending just before it.
1513					for dep in &task_meta.deps {
1514						let dep_exists = waiting.contains_key(dep)
1515							|| self.tasks_running.lock().ok().is_some_and(|r| r.contains_key(dep))
1516							|| self
1517								.tasks_scheduled
1518								.lock()
1519								.ok()
1520								.is_some_and(|s| s.iter().any(|((_, task_id), _)| task_id == dep));
1521
1522						if !dep_exists {
1523							tasks_with_missing_deps.push((*id, *dep));
1524							warn!(
1525								"SCHEDULER HEALTH: Task {} depends on non-existent task {}",
1526								id, dep
1527							);
1528						}
1529					}
1530				}
1531			}
1532		}
1533
1534		Ok(SchedulerHealth {
1535			waiting: waiting_count,
1536			scheduled: scheduled_count,
1537			running: running_count,
1538			dependents: dependents_count,
1539			stuck_tasks,
1540			tasks_with_missing_deps,
1541		})
1542	}
1543}
1544
1545/// Health status of the scheduler
1546#[derive(Debug, Clone)]
1547pub struct SchedulerHealth {
1548	/// Number of tasks waiting for dependencies
1549	pub waiting: usize,
1550	/// Number of tasks scheduled for future execution
1551	pub scheduled: usize,
1552	/// Number of tasks currently running
1553	pub running: usize,
1554	/// Number of task entries in dependents map
1555	pub dependents: usize,
1556	/// IDs of tasks with no dependencies but still in waiting queue
1557	pub stuck_tasks: Vec<TaskId>,
1558	/// Pairs of (task_id, missing_dependency_id) where dependency doesn't exist
1559	pub tasks_with_missing_deps: Vec<(TaskId, TaskId)>,
1560}
1561
1562#[cfg(test)]
1563mod tests {
1564	use super::*;
1565	use serde::{Deserialize, Serialize};
1566
1567	type State = Arc<Mutex<Vec<u8>>>;
1568
1569	#[derive(Debug, Serialize, Deserialize)]
1570	struct TestTask {
1571		num: u8,
1572	}
1573
1574	impl TestTask {
1575		pub fn new(num: u8) -> Arc<Self> {
1576			Arc::new(Self { num })
1577		}
1578	}
1579
1580	#[async_trait]
1581	impl Task<State> for TestTask {
1582		fn kind() -> &'static str {
1583			"test"
1584		}
1585
1586		fn build(_id: TaskId, ctx: &str) -> ClResult<Arc<dyn Task<State>>> {
1587			let num: u8 = ctx
1588				.parse()
1589				.map_err(|_| Error::Internal("test task context must be u8".into()))?;
1590			let task = TestTask::new(num);
1591			Ok(task)
1592		}
1593
1594		fn serialize(&self) -> String {
1595			self.num.to_string()
1596		}
1597
1598		fn kind_of(&self) -> &'static str {
1599			"test"
1600		}
1601
1602		async fn run(&self, state: &State) -> ClResult<()> {
1603			info!("Running task {}", self.num);
1604			tokio::time::sleep(std::time::Duration::from_millis(200 * u64::from(self.num))).await;
1605			info!("Completed task {}", self.num);
1606			state.lock().unwrap().push(self.num);
1607			Ok(())
1608		}
1609	}
1610
1611	#[derive(Debug, Clone)]
1612	struct FailingTask {
1613		id: u8,
1614		fail_count: u8,
1615		attempt: Arc<Mutex<u8>>,
1616		/// `attempt` argument of every `on_attempt_failed` call, in order.
1617		retried: Arc<Mutex<Vec<u16>>>,
1618		/// `attempts` argument of every (terminal) `on_failed` call, in order.
1619		gave_up: Arc<Mutex<Vec<u16>>>,
1620	}
1621
1622	impl FailingTask {
1623		pub fn new(id: u8, fail_count: u8) -> Arc<Self> {
1624			Arc::new(Self {
1625				id,
1626				fail_count,
1627				attempt: Arc::new(Mutex::new(0)),
1628				retried: Arc::new(Mutex::new(Vec::new())),
1629				gave_up: Arc::new(Mutex::new(Vec::new())),
1630			})
1631		}
1632	}
1633
1634	#[async_trait]
1635	impl Task<State> for FailingTask {
1636		fn kind() -> &'static str {
1637			"failing"
1638		}
1639
1640		fn build(_id: TaskId, ctx: &str) -> ClResult<Arc<dyn Task<State>>> {
1641			let parts: Vec<&str> = ctx.split(',').collect();
1642			if parts.len() != 2 {
1643				return Err(Error::Internal("failing task context must have 2 parts".into()));
1644			}
1645			let id: u8 = parts[0]
1646				.parse()
1647				.map_err(|_| Error::Internal("failing task id must be u8".into()))?;
1648			let fail_count: u8 = parts[1]
1649				.parse()
1650				.map_err(|_| Error::Internal("failing task fail_count must be u8".into()))?;
1651			Ok(FailingTask::new(id, fail_count))
1652		}
1653
1654		fn serialize(&self) -> String {
1655			format!("{},{}", self.id, self.fail_count)
1656		}
1657
1658		fn kind_of(&self) -> &'static str {
1659			"failing"
1660		}
1661
1662		async fn run(&self, state: &State) -> ClResult<()> {
1663			let mut attempt = self.attempt.lock().unwrap();
1664			*attempt += 1;
1665			let current_attempt = *attempt;
1666
1667			info!("FailingTask {} - attempt {}/{}", self.id, current_attempt, self.fail_count + 1);
1668
1669			if current_attempt <= self.fail_count {
1670				error!("FailingTask {} failed on attempt {}", self.id, current_attempt);
1671				return Err(Error::ServiceUnavailable(format!("Task {} failed", self.id)));
1672			}
1673
1674			info!("FailingTask {} succeeded on attempt {}", self.id, current_attempt);
1675			state.lock().unwrap().push(self.id);
1676			Ok(())
1677		}
1678
1679		async fn on_attempt_failed(&self, _state: &State, attempt: u16, _last_error: &str) {
1680			self.retried.lock().unwrap().push(attempt);
1681		}
1682
1683		async fn on_failed(&self, _state: &State, attempts: u16, _last_error: &str) {
1684			self.gave_up.lock().unwrap().push(attempts);
1685		}
1686	}
1687
1688	#[test]
1689	fn test_calculate_backoff() {
1690		let policy = RetryPolicy::new((10, 43200), 50);
1691		assert_eq!(policy.calculate_backoff(0), 10);
1692		assert_eq!(policy.calculate_backoff(1), 20);
1693		assert_eq!(policy.calculate_backoff(4), 160);
1694		// Past the cap.
1695		assert_eq!(policy.calculate_backoff(20), 43200);
1696
1697		// `times` is a `u16` and `new` is public, so an attempt count wider than
1698		// the shift is reachable. It must saturate to `max`, not panic.
1699		let wide = RetryPolicy::new((10, 43200), 200);
1700		assert_eq!(wide.calculate_backoff(63), 43200);
1701		assert_eq!(wide.calculate_backoff(64), 43200);
1702		assert_eq!(wide.calculate_backoff(200), 43200);
1703		assert_eq!(wide.calculate_backoff(u16::MAX), 43200);
1704	}
1705
1706	#[tokio::test]
1707	pub async fn test_scheduler() {
1708		let _ = tracing_subscriber::fmt().try_init();
1709
1710		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
1711		let state: State = Arc::new(Mutex::new(Vec::new()));
1712		let scheduler = Scheduler::new(task_store);
1713		scheduler.start(state.clone());
1714		scheduler.register::<TestTask>().unwrap();
1715
1716		let _task1 = TestTask::new(1);
1717		let task2 = TestTask::new(1);
1718		let task3 = TestTask::new(1);
1719
1720		let task2_id = scheduler.task(task2).schedule_after(2).schedule().await.unwrap();
1721		let task3_id = scheduler.add(task3).await.unwrap();
1722		scheduler
1723			.task(TestTask::new(1))
1724			.depend_on(vec![task2_id, task3_id])
1725			.schedule()
1726			.await
1727			.unwrap();
1728
1729		tokio::time::sleep(std::time::Duration::from_secs(4)).await;
1730		let task4 = TestTask::new(1);
1731		let task5 = TestTask::new(1);
1732		scheduler.task(task4).schedule_after(2).schedule().await.unwrap();
1733		scheduler.task(task5).schedule_after(1).schedule().await.unwrap();
1734
1735		tokio::time::sleep(std::time::Duration::from_secs(3)).await;
1736
1737		let st = state.lock().unwrap();
1738		info!("res: {}", st.len());
1739		let str_vec = st.iter().map(std::string::ToString::to_string).collect::<Vec<String>>();
1740		assert_eq!(str_vec.join(":"), "1:1:1:1:1");
1741	}
1742
1743	#[tokio::test]
1744	pub async fn test_retry_with_backoff() {
1745		let _ = tracing_subscriber::fmt().try_init();
1746
1747		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
1748		let state: State = Arc::new(Mutex::new(Vec::new()));
1749		let scheduler = Scheduler::new(task_store);
1750		scheduler.start(state.clone());
1751		scheduler.register::<FailingTask>().unwrap();
1752
1753		// Create a task that fails twice, then succeeds
1754		// With retry policy: min=1s, max=3600s, max_attempts=3
1755		let failing_task = FailingTask::new(42, 2);
1756		let retried = failing_task.retried.clone();
1757		let gave_up = failing_task.gave_up.clone();
1758		let retry_policy = RetryPolicy { wait_min_max: (1, 3600), times: 3 };
1759
1760		scheduler.task(failing_task).with_retry(retry_policy).schedule().await.unwrap();
1761
1762		// Wait for retries: 1s (1st fail) + 1s (2nd fail) + time for success
1763		// First attempt: immediate fail
1764		// Wait 1s (min backoff)
1765		// Second attempt: fail
1766		// Wait 2s (min * 2)
1767		// Third attempt: success
1768		tokio::time::sleep(std::time::Duration::from_secs(6)).await;
1769
1770		{
1771			let st = state.lock().unwrap();
1772			assert_eq!(st.len(), 1, "Task should have succeeded after retries");
1773			assert_eq!(st[0], 42);
1774		}
1775
1776		// A task that fails before it succeeds must be able to tell the difference
1777		// between "failed, retrying" and "failed for good": `on_attempt_failed` sees
1778		// each retried failure with its zero-based attempt index, `on_failed` none.
1779		assert_eq!(
1780			retried.lock().unwrap().as_slice(),
1781			&[0, 1],
1782			"Both retried failures should report their zero-based attempt index"
1783		);
1784		assert!(gave_up.lock().unwrap().is_empty(), "on_failed is only for terminal failures");
1785	}
1786
1787	// ===== Builder Pattern Tests =====
1788
1789	#[tokio::test]
1790	pub async fn test_builder_simple_schedule() {
1791		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
1792		let state: State = Arc::new(Mutex::new(Vec::new()));
1793		let scheduler = Scheduler::new(task_store);
1794		scheduler.start(state.clone());
1795		scheduler.register::<TestTask>().unwrap();
1796
1797		// Test basic builder usage: .now()
1798		let task = TestTask::new(1);
1799		let id = scheduler.task(task).now().await.unwrap();
1800
1801		assert!(id > 0, "Task ID should be positive");
1802
1803		tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1804
1805		let st = state.lock().unwrap();
1806		assert_eq!(st.len(), 1, "Task should have executed");
1807		assert_eq!(st[0], 1);
1808	}
1809
1810	#[tokio::test]
1811	pub async fn test_builder_with_key() {
1812		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
1813		let state: State = Arc::new(Mutex::new(Vec::new()));
1814		let scheduler = Scheduler::new(task_store);
1815		scheduler.start(state.clone());
1816		scheduler.register::<TestTask>().unwrap();
1817
1818		// Test builder with key
1819		let task = TestTask::new(1);
1820		let _id = scheduler.task(task).key("my-task-key").now().await.unwrap();
1821
1822		tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1823
1824		let st = state.lock().unwrap();
1825		assert_eq!(st.len(), 1);
1826		assert_eq!(st[0], 1);
1827	}
1828
1829	#[tokio::test]
1830	pub async fn test_builder_with_delay() {
1831		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
1832		let state: State = Arc::new(Mutex::new(Vec::new()));
1833		let scheduler = Scheduler::new(task_store);
1834		scheduler.start(state.clone());
1835		scheduler.register::<TestTask>().unwrap();
1836
1837		// Test builder with .after() convenience method
1838		let task = TestTask::new(1);
1839		let _id = scheduler
1840			.task(task)
1841			.after(1)  // 1 second delay
1842			.await
1843			.unwrap();
1844
1845		// Should not have executed yet
1846		tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1847		{
1848			let st = state.lock().unwrap();
1849			assert_eq!(st.len(), 0, "Task should not execute yet");
1850		}
1851
1852		// Wait for execution (1 sec delay + 200ms task sleep + buffer)
1853		tokio::time::sleep(std::time::Duration::from_millis(800)).await;
1854
1855		{
1856			let st = state.lock().unwrap();
1857			assert_eq!(st.len(), 1, "Task should have executed");
1858			assert_eq!(st[0], 1);
1859		}
1860	}
1861
1862	#[tokio::test]
1863	pub async fn test_builder_with_dependencies() {
1864		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
1865		let state: State = Arc::new(Mutex::new(Vec::new()));
1866		let scheduler = Scheduler::new(task_store);
1867		scheduler.start(state.clone());
1868		scheduler.register::<TestTask>().unwrap();
1869
1870		// Create first task (sleeps 200ms)
1871		let task1 = TestTask::new(1);
1872		let id1 = scheduler.task(task1).now().await.unwrap();
1873
1874		// Create second task (sleeps 400ms)
1875		let task2 = TestTask::new(1);
1876		let id2 = scheduler.task(task2).now().await.unwrap();
1877
1878		// Create third task that depends on first two (sleeps 600ms)
1879		let task3 = TestTask::new(1);
1880		let _id3 = scheduler.task(task3).depend_on(vec![id1, id2]).schedule().await.unwrap();
1881
1882		// Wait for all tasks: task1 200ms, task2 400ms, task3 600ms = ~1200ms
1883		tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
1884
1885		let st = state.lock().unwrap();
1886		// Should have all three tasks in execution order: 1 finishes first (200ms), then 2 (200ms), then 3 (200ms after both)
1887		let str_vec = st.iter().map(std::string::ToString::to_string).collect::<Vec<String>>();
1888		assert_eq!(str_vec.join(":"), "1:1:1");
1889	}
1890
1891	#[tokio::test]
1892	pub async fn test_builder_with_retry() {
1893		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
1894		let state: State = Arc::new(Mutex::new(Vec::new()));
1895		let scheduler = Scheduler::new(task_store);
1896		scheduler.start(state.clone());
1897		scheduler.register::<FailingTask>().unwrap();
1898
1899		// Create task using builder with retry policy
1900		let failing_task = FailingTask::new(55, 1); // Fails once, succeeds second time
1901		let retry_policy = RetryPolicy { wait_min_max: (1, 3600), times: 3 };
1902
1903		let _id = scheduler.task(failing_task).with_retry(retry_policy).schedule().await.unwrap();
1904
1905		// Wait for retry cycle: 1 fail + 1s wait + 1 success
1906		tokio::time::sleep(std::time::Duration::from_secs(3)).await;
1907
1908		let st = state.lock().unwrap();
1909		assert_eq!(st.len(), 1);
1910		assert_eq!(st[0], 55);
1911	}
1912
1913	#[tokio::test]
1914	pub async fn test_builder_with_automatic_retry() {
1915		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
1916		let state: State = Arc::new(Mutex::new(Vec::new()));
1917		let scheduler = Scheduler::new(task_store);
1918		scheduler.start(state.clone());
1919		scheduler.register::<FailingTask>().unwrap();
1920
1921		// Create task using builder with automatic retry (default policy)
1922		let failing_task = FailingTask::new(66, 1);
1923		let _id = scheduler.task(failing_task).with_automatic_retry().await.unwrap();
1924
1925		// Wait for retry cycle with default policy (min=60s would be too long for test)
1926		// but we already tested retry logic thoroughly, just verify builder integration
1927		tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1928
1929		// The important part is that this compiles and integrates correctly
1930		let st = state.lock().unwrap();
1931		// With default policy (min=60s), task shouldn't succeed in test timeframe
1932		// Just verify builder chaining works
1933		let _ = st.len(); // Verify state is accessible, but don't assert on timeout-dependent result
1934	}
1935
1936	#[tokio::test]
1937	pub async fn test_builder_fluent_chaining() {
1938		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
1939		let state: State = Arc::new(Mutex::new(Vec::new()));
1940		let scheduler = Scheduler::new(task_store);
1941		scheduler.start(state.clone());
1942		scheduler.register::<TestTask>().unwrap();
1943
1944		// Create first dependencies
1945		let dep1 = scheduler.task(TestTask::new(1)).now().await.unwrap();
1946		let dep2 = scheduler.task(TestTask::new(1)).now().await.unwrap();
1947
1948		// Test fluent chaining with multiple methods
1949		let retry_policy = RetryPolicy { wait_min_max: (1, 3600), times: 3 };
1950
1951		let task = TestTask::new(1);
1952		let _id = scheduler
1953			.task(task)
1954			.key("complex-task")
1955			.schedule_after(0)  // Schedule immediately
1956			.depend_on(vec![dep1, dep2])
1957			.with_retry(retry_policy)
1958			.schedule()
1959			.await
1960			.unwrap();
1961
1962		tokio::time::sleep(std::time::Duration::from_millis(800)).await;
1963
1964		let st = state.lock().unwrap();
1965		// Should have all tasks: 20:10 (immediate deps) then 30 (after deps)
1966		let str_vec = st.iter().map(std::string::ToString::to_string).collect::<Vec<String>>();
1967		assert_eq!(str_vec.join(":"), "1:1:1");
1968	}
1969
1970	#[tokio::test]
1971	pub async fn test_builder_backward_compatibility() {
1972		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
1973		let state: State = Arc::new(Mutex::new(Vec::new()));
1974		let scheduler = Scheduler::new(task_store);
1975		scheduler.start(state.clone());
1976		scheduler.register::<TestTask>().unwrap();
1977
1978		// Test that old API still works
1979		let _id1 = scheduler.add(TestTask::new(1)).await.unwrap();
1980
1981		// Test that new builder API works
1982		let _id2 = scheduler.task(TestTask::new(1)).now().await.unwrap();
1983
1984		tokio::time::sleep(std::time::Duration::from_millis(800)).await;
1985
1986		let st = state.lock().unwrap();
1987		// Both old and new API should have executed
1988		assert_eq!(st.len(), 2);
1989		let str_vec = st.iter().map(std::string::ToString::to_string).collect::<Vec<String>>();
1990		assert_eq!(str_vec.join(":"), "1:1");
1991	}
1992
1993	// ===== Phase 2: Integration Tests - Real-world scenarios =====
1994
1995	#[tokio::test]
1996	pub async fn test_builder_pipeline_scenario() {
1997		// Simulates: Task 1 -> Task 2 (depends on 1) -> Task 3 (depends on 2)
1998		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
1999		let state: State = Arc::new(Mutex::new(Vec::new()));
2000		let scheduler = Scheduler::new(task_store);
2001		scheduler.start(state.clone());
2002		scheduler.register::<TestTask>().unwrap();
2003
2004		// Stage 1: Create initial task
2005		let id1 = scheduler.task(TestTask::new(1)).key("stage-1").now().await.unwrap();
2006
2007		// Stage 2: Create task that depends on stage 1
2008		let id2 = scheduler.task(TestTask::new(1)).key("stage-2").after_task(id1).await.unwrap();
2009
2010		// Stage 3: Create task that depends on stage 2
2011		let _id3 = scheduler.task(TestTask::new(1)).key("stage-3").after_task(id2).await.unwrap();
2012
2013		// Wait for pipeline: 1(200ms) + 2(200ms) + 3(200ms) = 600ms
2014		tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
2015
2016		let st = state.lock().unwrap();
2017		// Should execute in order: 1, 2, 3
2018		let str_vec = st.iter().map(std::string::ToString::to_string).collect::<Vec<String>>();
2019		assert_eq!(str_vec.join(":"), "1:1:1");
2020	}
2021
2022	#[tokio::test]
2023	pub async fn test_builder_multi_dependency_join() {
2024		// Simulates: Task 1 parallel with Task 2, then Task 3 waits for both
2025		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
2026		let state: State = Arc::new(Mutex::new(Vec::new()));
2027		let scheduler = Scheduler::new(task_store);
2028		scheduler.start(state.clone());
2029		scheduler.register::<TestTask>().unwrap();
2030
2031		// Parallel tasks
2032		let id1 = scheduler.task(TestTask::new(1)).now().await.unwrap();
2033		let id2 = scheduler.task(TestTask::new(1)).now().await.unwrap();
2034
2035		// Join task - waits for both
2036		let _id3 = scheduler
2037			.task(TestTask::new(1))
2038			.depend_on(vec![id1, id2])
2039			.schedule()
2040			.await
2041			.unwrap();
2042
2043		tokio::time::sleep(std::time::Duration::from_secs(1)).await;
2044
2045		let st = state.lock().unwrap();
2046		// 1 and 2 execute in parallel, then 3 executes after both
2047		let str_vec = st.iter().map(std::string::ToString::to_string).collect::<Vec<String>>();
2048		assert_eq!(str_vec.join(":"), "1:1:1");
2049	}
2050
2051	#[tokio::test]
2052	pub async fn test_builder_scheduled_task_with_dependencies() {
2053		// Simulates: Task depends on earlier task AND is scheduled for future time
2054		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
2055		let state: State = Arc::new(Mutex::new(Vec::new()));
2056		let scheduler = Scheduler::new(task_store);
2057		scheduler.start(state.clone());
2058		scheduler.register::<TestTask>().unwrap();
2059
2060		// Immediate task
2061		let dep_id = scheduler.task(TestTask::new(1)).now().await.unwrap();
2062
2063		// Task that waits for dependency AND scheduled delay
2064		let ts = Timestamp::from_now(1);
2065		let _task_id = scheduler
2066			.task(TestTask::new(1))
2067			.schedule_at(ts)
2068			.depend_on(vec![dep_id])
2069			.schedule()
2070			.await
2071			.unwrap();
2072
2073		// Wait for dependency to complete but before scheduled time
2074		tokio::time::sleep(std::time::Duration::from_millis(300)).await;
2075		{
2076			let st = state.lock().unwrap();
2077			assert_eq!(st.len(), 1); // Only dependency executed
2078		}
2079
2080		// Wait for scheduled time (1s total from initial schedule)
2081		tokio::time::sleep(std::time::Duration::from_millis(800)).await;
2082
2083		{
2084			let st = state.lock().unwrap();
2085			let str_vec = st.iter().map(std::string::ToString::to_string).collect::<Vec<String>>();
2086			assert_eq!(str_vec.join(":"), "1:1");
2087		}
2088	}
2089
2090	#[tokio::test]
2091	pub async fn test_builder_mixed_features() {
2092		// Simulates: Complex real-world scenario with key, scheduling, deps, and retry
2093		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
2094		let state: State = Arc::new(Mutex::new(Vec::new()));
2095		let scheduler = Scheduler::new(task_store);
2096		scheduler.start(state.clone());
2097		scheduler.register::<TestTask>().unwrap();
2098		scheduler.register::<FailingTask>().unwrap();
2099
2100		// Create initial tasks
2101		let id1 = scheduler.task(TestTask::new(1)).now().await.unwrap();
2102
2103		// Create complex task: scheduled + depends on id1 + has key
2104		let _id2 = scheduler
2105			.task(TestTask::new(1))
2106			.key("critical-task")
2107			.schedule_after(0)
2108			.depend_on(vec![id1])
2109			.schedule()
2110			.await
2111			.unwrap();
2112
2113		// Create task with retry
2114		let _id3 = scheduler
2115			.task(FailingTask::new(1, 0))  // Fails 0 times, succeeds immediately
2116			.key("retryable-task")
2117			.with_retry(RetryPolicy {
2118				wait_min_max: (1, 3600),
2119				times: 3,
2120			})
2121			.schedule()
2122			.await
2123			.unwrap();
2124
2125		// Wait for tasks: id1 (200ms) + id2 (200ms after id1) + id3 (200ms) = ~600ms
2126		tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
2127
2128		let st = state.lock().unwrap();
2129		// All three tasks should execute
2130		let str_vec = st.iter().map(std::string::ToString::to_string).collect::<Vec<String>>();
2131		assert_eq!(str_vec.join(":"), "1:1:1");
2132	}
2133
2134	#[tokio::test]
2135	pub async fn test_builder_builder_reuse_not_possible() {
2136		// Verify that builder is consumed (moved) and can't be reused
2137		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
2138		let _state: State = Arc::new(Mutex::new(Vec::new()));
2139		let scheduler = Scheduler::new(task_store);
2140
2141		let task = TestTask::new(1);
2142		let builder = scheduler.task(task);
2143
2144		// This would not compile if uncommented (builder is moved):
2145		// let _id1 = builder.now().await.unwrap();
2146		// let _id2 = builder.now().await.unwrap();  // Error: use of moved value
2147
2148		// Can only call terminal method once
2149		let _id = builder.now().await.unwrap();
2150		// builder is now consumed, can't use again
2151
2152		// Test passes if it compiles (verifying move semantics)
2153	}
2154
2155	#[tokio::test]
2156	pub async fn test_builder_different_task_types() {
2157		// Test builder works with different task implementations
2158		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
2159		let state: State = Arc::new(Mutex::new(Vec::new()));
2160		let scheduler = Scheduler::new(task_store);
2161		scheduler.start(state.clone());
2162		scheduler.register::<TestTask>().unwrap();
2163		scheduler.register::<FailingTask>().unwrap();
2164
2165		// Mix of different task types
2166		let _id1 = scheduler.task(TestTask::new(1)).key("test-task").now().await.unwrap();
2167
2168		let _id2 = scheduler
2169			.task(FailingTask::new(1, 0))  // Won't fail
2170			.key("failing-task")
2171			.now()
2172			.await
2173			.unwrap();
2174
2175		let _id3 = scheduler.task(TestTask::new(1)).now().await.unwrap();
2176
2177		tokio::time::sleep(std::time::Duration::from_secs(1)).await;
2178
2179		let st = state.lock().unwrap();
2180		assert_eq!(st.len(), 3);
2181		let str_vec = st.iter().map(std::string::ToString::to_string).collect::<Vec<String>>();
2182		// All three tasks should execute
2183		assert_eq!(str_vec.join(":"), "1:1:1");
2184	}
2185
2186	// ===== Phase 3: Cron Placeholder Tests =====
2187	// These tests verify that cron methods compile and integrate
2188	// Actual cron functionality will be implemented in Phase 3
2189
2190	#[tokio::test]
2191	pub async fn test_builder_cron_placeholder_syntax() {
2192		// Verify cron placeholder methods compile and chain properly
2193		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
2194		let state: State = Arc::new(Mutex::new(Vec::new()));
2195		let scheduler = Scheduler::new(task_store);
2196		scheduler.start(state.clone());
2197		scheduler.register::<TestTask>().unwrap();
2198
2199		// Test that cron methods compile (they're no-ops in Phase 2)
2200		let task = TestTask::new(1);
2201		let _id = scheduler
2202			.task(task)
2203			.key("cron-task")
2204			.cron("0 9 * * *")  // 9 AM daily
2205			.schedule()
2206			.await
2207			.unwrap();
2208
2209		// Cron scheduling - task will execute at the next scheduled time
2210		// For cron "0 9 * * *", that's tomorrow at 9 AM, so task won't execute in this test
2211		// This test just verifies the methods compile and chain properly
2212		tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2213
2214		let st = state.lock().unwrap();
2215		// Task is scheduled for future (9 AM), so it won't have executed yet
2216		// The important thing is that the cron methods compile and integrate
2217		assert_eq!(st.len(), 0); // Not executed yet since scheduled for future
2218	}
2219
2220	#[tokio::test]
2221	pub async fn test_builder_daily_at_placeholder() {
2222		// Verify daily_at placeholder compiles and integrates
2223		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
2224		let state: State = Arc::new(Mutex::new(Vec::new()));
2225		let scheduler = Scheduler::new(task_store);
2226		scheduler.start(state.clone());
2227		scheduler.register::<TestTask>().unwrap();
2228
2229		// Test that daily_at placeholder compiles
2230		let task = TestTask::new(1);
2231		let _id = scheduler
2232			.task(task)
2233			.key("daily-task")
2234			.daily_at(14, 30)  // 2:30 PM daily
2235			.schedule()
2236			.await
2237			.unwrap();
2238
2239		// Daily_at scheduling - task will execute at the specified time (2:30 PM daily)
2240		// Task is scheduled for future, so it won't execute in this test
2241		tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2242
2243		let st = state.lock().unwrap();
2244		// Task is scheduled for future (2:30 PM), not executed yet
2245		// The important thing is that daily_at compiles and integrates properly
2246		assert_eq!(st.len(), 0);
2247	}
2248
2249	#[tokio::test]
2250	pub async fn test_builder_weekly_at_placeholder() {
2251		// Verify weekly_at placeholder compiles and integrates
2252		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
2253		let state: State = Arc::new(Mutex::new(Vec::new()));
2254		let scheduler = Scheduler::new(task_store);
2255		scheduler.start(state.clone());
2256		scheduler.register::<TestTask>().unwrap();
2257
2258		// Test that weekly_at placeholder compiles
2259		let task = TestTask::new(1);
2260		let _id = scheduler
2261			.task(task)
2262			.key("weekly-task")
2263			.weekly_at(1, 9, 0)  // Monday at 9 AM
2264			.schedule()
2265			.await
2266			.unwrap();
2267
2268		// Weekly_at scheduling - task will execute on Monday at 9 AM
2269		// Task is scheduled for future, so it won't execute in this test
2270		tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2271
2272		let st = state.lock().unwrap();
2273		// Task is scheduled for future (Monday 9 AM), not executed yet
2274		// The important thing is that weekly_at compiles and integrates properly
2275		assert_eq!(st.len(), 0);
2276	}
2277
2278	#[tokio::test]
2279	pub async fn test_builder_cron_with_retry() {
2280		// Verify cron methods chain with retry (future combined usage)
2281		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
2282		let state: State = Arc::new(Mutex::new(Vec::new()));
2283		let scheduler = Scheduler::new(task_store);
2284		scheduler.start(state.clone());
2285		scheduler.register::<TestTask>().unwrap();
2286
2287		// Test future usage pattern: cron + retry
2288		let task = TestTask::new(1);
2289		let _id = scheduler
2290			.task(task)
2291			.key("reliable-scheduled-task")
2292			.daily_at(2, 0)  // 2 AM daily
2293			.with_retry(RetryPolicy {
2294				wait_min_max: (60, 3600),
2295				times: 5,
2296			})
2297			.schedule()
2298			.await
2299			.unwrap();
2300
2301		// Verify cron+retry chain compiles properly
2302		// Task is scheduled for 2 AM, so won't execute in this test
2303		tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2304
2305		let st = state.lock().unwrap();
2306		// Task scheduled for future (2 AM), not executed yet
2307		// The important thing is that chaining cron + retry works
2308		assert_eq!(st.len(), 0);
2309	}
2310
2311	// ===== Cron Schedule Tests =====
2312
2313	#[test]
2314	fn test_cron_to_string() {
2315		// Test that to_cron_string returns the original expression
2316		let cron = CronSchedule::parse("*/5 * * * *").unwrap();
2317		assert_eq!(cron.to_cron_string(), "*/5 * * * *");
2318	}
2319
2320	#[tokio::test]
2321	pub async fn test_running_task_not_double_scheduled() {
2322		let _ = tracing_subscriber::fmt().try_init();
2323
2324		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
2325		let state: State = Arc::new(Mutex::new(Vec::new()));
2326		let scheduler = Scheduler::new(task_store);
2327		scheduler.start(state.clone());
2328		scheduler.register::<TestTask>().unwrap();
2329
2330		// Create a task
2331		let task = TestTask::new(5); // Takes 1 second (5 * 200ms)
2332		let task_id = scheduler.add(task.clone()).await.unwrap();
2333
2334		// Wait a bit for task to start running
2335		tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2336
2337		// Verify task is in tasks_running
2338		{
2339			let running = scheduler.tasks_running.lock().unwrap();
2340			assert!(running.contains_key(&task_id), "Task should be in running queue");
2341		}
2342
2343		// Try to add the same task again via add_queue
2344		let task_meta = TaskMeta {
2345			task: task.clone(),
2346			next_at: Some(Timestamp::now()),
2347			deps: vec![],
2348			retry_count: 0,
2349			retry: None,
2350			cron: None,
2351			rerun_requested: false,
2352		};
2353		let result = scheduler.add_queue(task_id, task_meta).await;
2354
2355		// Should succeed but not actually add to scheduled queue
2356		assert!(result.is_ok(), "add_queue should succeed");
2357
2358		// Verify task is NOT in tasks_scheduled (only in running)
2359		{
2360			let sched_queue = scheduler.tasks_scheduled.lock().unwrap();
2361			let in_scheduled = sched_queue.iter().any(|((_, id), _)| *id == task_id);
2362			assert!(!in_scheduled, "Task should NOT be in scheduled queue while running");
2363		}
2364
2365		// Wait for original task to complete
2366		tokio::time::sleep(std::time::Duration::from_secs(2)).await;
2367
2368		// Verify task completed
2369		let st = state.lock().unwrap();
2370		assert_eq!(st.len(), 1, "Only one task execution should have occurred");
2371		assert_eq!(st[0], 5);
2372	}
2373
2374	#[tokio::test]
2375	pub async fn test_running_task_metadata_updated() {
2376		let _ = tracing_subscriber::fmt().try_init();
2377
2378		let task_store: Arc<dyn TaskStore<State>> = InMemoryTaskStore::new();
2379		let state: State = Arc::new(Mutex::new(Vec::new()));
2380		let scheduler = Scheduler::new(task_store);
2381		scheduler.start(state.clone());
2382		scheduler.register::<TestTask>().unwrap();
2383
2384		// Create a task without cron
2385		let task = TestTask::new(5); // Takes 1 second (5 * 200ms)
2386		let task_id = scheduler.add(task.clone()).await.unwrap();
2387
2388		// Wait a bit for task to start running
2389		tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2390
2391		// Verify task is running and has no cron
2392		{
2393			let running = scheduler.tasks_running.lock().unwrap();
2394			let meta = running.get(&task_id).expect("Task should be running");
2395			assert!(meta.cron.is_none(), "Task should have no cron initially");
2396		}
2397
2398		// Try to update the running task with a cron schedule
2399		let cron = CronSchedule::parse("*/5 * * * *").unwrap();
2400		let task_meta_with_cron = TaskMeta {
2401			task: task.clone(),
2402			next_at: Some(Timestamp::now()),
2403			deps: vec![],
2404			retry_count: 0,
2405			retry: None,
2406			cron: Some(cron.clone()),
2407			rerun_requested: false,
2408		};
2409		let result = scheduler.add_queue(task_id, task_meta_with_cron).await;
2410
2411		// Should succeed
2412		assert!(result.is_ok(), "add_queue should succeed");
2413
2414		// Verify the running task now has the cron schedule
2415		{
2416			let running = scheduler.tasks_running.lock().unwrap();
2417			let meta = running.get(&task_id).expect("Task should still be running");
2418			assert!(meta.cron.is_some(), "Task should now have cron after update");
2419		}
2420
2421		// Wait for task to complete
2422		tokio::time::sleep(std::time::Duration::from_secs(2)).await;
2423	}
2424
2425	/// A store that actually remembers keys.
2426	///
2427	/// [`InMemoryTaskStore::find_by_key`] always answers `None`, so it can never
2428	/// reach the keyed-dedup branch of `schedule_task_impl` — the branch the test
2429	/// below is about. Only `input` has to be faithful: that is what the
2430	/// "parameters changed" comparison reads.
2431	struct KeyedTaskStore {
2432		last_id: Mutex<TaskId>,
2433		by_key: Mutex<HashMap<String, TaskId>>,
2434		input: Mutex<HashMap<TaskId, (String, String)>>,
2435		/// Every `finished` call, in order. The persisted row leaves `status='P'`
2436		/// only through this, so a run that never reaches it is a task stuck
2437		/// pending forever — re-run on every process restart, dependents never
2438		/// released.
2439		finished: Mutex<Vec<TaskId>>,
2440		/// Every `update_task_error` call, in order. `next_at` is what decides the
2441		/// persisted status: `Some` keeps the row `'P'` (a cron task is not
2442		/// finished by a failed run), `None` stamps it `'E'`.
2443		errors: Mutex<Vec<(TaskId, Option<Timestamp>)>>,
2444		/// Hold `finished` open so a test can land a re-request *inside* that
2445		/// await, which is where the race lives. Separate from `update_gate`
2446		/// because a test that parks one still needs the other to run: the
2447		/// re-request it lands does its own store call on the way in.
2448		finished_gate: Arc<tokio::sync::Semaphore>,
2449		/// Same, for `update_task` — the cron reschedule's await.
2450		update_gate: Arc<tokio::sync::Semaphore>,
2451		/// Fires as `finished` is entered, before it parks.
2452		finished_entered: Arc<tokio::sync::Notify>,
2453		/// Fires as `update_task` is entered, before it parks.
2454		updated_entered: Arc<tokio::sync::Notify>,
2455	}
2456
2457	impl KeyedTaskStore {
2458		/// Both gates wide open — the ungated store every other test uses.
2459		fn new() -> Arc<Self> {
2460			Self::with_gates(
2461				tokio::sync::Semaphore::MAX_PERMITS,
2462				tokio::sync::Semaphore::MAX_PERMITS,
2463			)
2464		}
2465
2466		/// A store whose `finished` blocks until the test releases it.
2467		fn new_gated_finished() -> Arc<Self> {
2468			Self::with_gates(0, tokio::sync::Semaphore::MAX_PERMITS)
2469		}
2470
2471		/// A store whose `update_task` blocks until the test releases it.
2472		fn new_gated_update() -> Arc<Self> {
2473			Self::with_gates(tokio::sync::Semaphore::MAX_PERMITS, 0)
2474		}
2475
2476		fn with_gates(finished_permits: usize, update_permits: usize) -> Arc<Self> {
2477			Arc::new(Self {
2478				last_id: Mutex::new(0),
2479				by_key: Mutex::new(HashMap::new()),
2480				input: Mutex::new(HashMap::new()),
2481				finished: Mutex::new(Vec::new()),
2482				errors: Mutex::new(Vec::new()),
2483				finished_gate: Arc::new(tokio::sync::Semaphore::new(finished_permits)),
2484				update_gate: Arc::new(tokio::sync::Semaphore::new(update_permits)),
2485				finished_entered: Arc::new(tokio::sync::Notify::new()),
2486				updated_entered: Arc::new(tokio::sync::Notify::new()),
2487			})
2488		}
2489
2490		fn finished_ids(&self) -> Vec<TaskId> {
2491			self.finished.lock().unwrap().clone()
2492		}
2493
2494		fn error_calls(&self) -> Vec<(TaskId, Option<Timestamp>)> {
2495			self.errors.lock().unwrap().clone()
2496		}
2497
2498		fn release_finished(&self, n: usize) {
2499			self.finished_gate.add_permits(n);
2500		}
2501
2502		fn release_updates(&self, n: usize) {
2503			self.update_gate.add_permits(n);
2504		}
2505
2506		/// Await `finished` being entered. `notify_one` stores a permit when
2507		/// nobody is waiting, so this cannot miss the signal. The timeout only
2508		/// exists so a regression fails instead of hanging.
2509		async fn await_finished_entered(&self) {
2510			tokio::time::timeout(
2511				std::time::Duration::from_secs(5),
2512				self.finished_entered.notified(),
2513			)
2514			.await
2515			.expect("expected `finished` to be entered");
2516		}
2517
2518		/// Same, for `update_task`.
2519		async fn await_updated_entered(&self) {
2520			tokio::time::timeout(
2521				std::time::Duration::from_secs(5),
2522				self.updated_entered.notified(),
2523			)
2524			.await
2525			.expect("expected `update_task` to be entered");
2526		}
2527	}
2528
2529	#[async_trait]
2530	impl<S: Clone> TaskStore<S> for KeyedTaskStore {
2531		async fn add(&self, task: &TaskMeta<S>, key: Option<&str>) -> ClResult<TaskId> {
2532			let id = {
2533				let mut last = self.last_id.lock().unwrap();
2534				*last += 1;
2535				*last
2536			};
2537			self.input
2538				.lock()
2539				.unwrap()
2540				.insert(id, (task.task.kind_of().to_owned(), task.task.serialize()));
2541			if let Some(key) = key {
2542				self.by_key.lock().unwrap().insert(key.to_owned(), id);
2543			}
2544			Ok(id)
2545		}
2546
2547		async fn find_by_key(&self, key: &str) -> ClResult<Option<(TaskId, TaskData)>> {
2548			let Some(id) = self.by_key.lock().unwrap().get(key).copied() else { return Ok(None) };
2549			let Some((kind, input)) = self.input.lock().unwrap().get(&id).cloned() else {
2550				return Ok(None);
2551			};
2552			Ok(Some((
2553				id,
2554				TaskData {
2555					id,
2556					kind: kind.into(),
2557					status: TaskStatus::Pending,
2558					input: input.into(),
2559					deps: Box::from([]),
2560					retry_data: None,
2561					cron_data: None,
2562					next_at: None,
2563				},
2564			)))
2565		}
2566
2567		async fn update_task(&self, id: TaskId, task: &TaskMeta<S>) -> ClResult<()> {
2568			self.updated_entered.notify_one();
2569			self.update_gate
2570				.acquire()
2571				.await
2572				.map(tokio::sync::SemaphorePermit::forget)
2573				.map_err(|_| Error::Internal("store gate closed".into()))?;
2574			if let Some(entry) = self.input.lock().unwrap().get_mut(&id) {
2575				entry.1 = task.task.serialize();
2576			}
2577			Ok(())
2578		}
2579
2580		async fn finished(&self, id: TaskId, _output: &str) -> ClResult<()> {
2581			self.finished_entered.notify_one();
2582			self.finished_gate
2583				.acquire()
2584				.await
2585				.map(tokio::sync::SemaphorePermit::forget)
2586				.map_err(|_| Error::Internal("store gate closed".into()))?;
2587			self.finished.lock().unwrap().push(id);
2588			Ok(())
2589		}
2590		async fn load(&self) -> ClResult<Vec<TaskData>> {
2591			Ok(vec![])
2592		}
2593		async fn update_task_error(
2594			&self,
2595			task_id: TaskId,
2596			_output: &str,
2597			next_at: Option<Timestamp>,
2598		) -> ClResult<()> {
2599			self.errors.lock().unwrap().push((task_id, next_at));
2600			Ok(())
2601		}
2602		async fn find_completed_deps(&self, _deps: &[TaskId]) -> ClResult<Vec<TaskId>> {
2603			Ok(vec![])
2604		}
2605	}
2606
2607	/// A task whose body a test drives directly: `entered` fires the moment a run
2608	/// starts, and the run then blocks until the test hands it a `gate` permit.
2609	///
2610	/// No clock is involved, in either direction — the tests below neither sleep
2611	/// on wall-clock time nor need `tokio::time::pause`, so they cannot be flaky
2612	/// on a loaded machine. `runs` records the parameters of every body entered,
2613	/// in order, which is what "the second run used the *new* parameters" means.
2614	#[derive(Debug)]
2615	struct GatedTask {
2616		param: u8,
2617		runs: Arc<Mutex<Vec<u8>>>,
2618		entered: Arc<tokio::sync::Notify>,
2619		gate: Arc<tokio::sync::Semaphore>,
2620		/// Return a retryable error once the gate opens.
2621		fail: bool,
2622	}
2623
2624	#[async_trait]
2625	impl Task<State> for GatedTask {
2626		fn kind() -> &'static str {
2627			"gated"
2628		}
2629		fn kind_of(&self) -> &'static str {
2630			Self::kind()
2631		}
2632		fn build(_id: TaskId, _ctx: &str) -> ClResult<Arc<dyn Task<State>>> {
2633			Err(Error::Internal("not rebuilt in this test".into()))
2634		}
2635		fn serialize(&self) -> String {
2636			self.param.to_string()
2637		}
2638		async fn run(&self, _state: &State) -> ClResult<()> {
2639			self.runs.lock().unwrap().push(self.param);
2640			self.entered.notify_one();
2641			let _permit = self.gate.acquire().await;
2642			if self.fail {
2643				// Retryable, so the retry path rather than `on_failed` runs.
2644				return Err(Error::Internal("gated failure".into()));
2645			}
2646			Ok(())
2647		}
2648	}
2649
2650	struct Gated {
2651		runs: Arc<Mutex<Vec<u8>>>,
2652		entered: Arc<tokio::sync::Notify>,
2653		gate: Arc<tokio::sync::Semaphore>,
2654	}
2655
2656	impl Gated {
2657		fn new() -> Self {
2658			Self {
2659				runs: Arc::new(Mutex::new(Vec::new())),
2660				entered: Arc::new(tokio::sync::Notify::new()),
2661				gate: Arc::new(tokio::sync::Semaphore::new(0)),
2662			}
2663		}
2664
2665		fn task(&self, param: u8, fail: bool) -> Arc<GatedTask> {
2666			Arc::new(GatedTask {
2667				param,
2668				runs: Arc::clone(&self.runs),
2669				entered: Arc::clone(&self.entered),
2670				gate: Arc::clone(&self.gate),
2671				fail,
2672			})
2673		}
2674
2675		fn params(&self) -> Vec<u8> {
2676			self.runs.lock().unwrap().clone()
2677		}
2678
2679		/// Await the next body being entered. The timeout only exists so a
2680		/// regression — a run that never happens — fails the test instead of
2681		/// hanging it; the happy path never reaches the clock.
2682		async fn await_run(&self) {
2683			tokio::time::timeout(std::time::Duration::from_secs(5), self.entered.notified())
2684				.await
2685				.expect("expected a task body to be entered");
2686		}
2687	}
2688
2689	/// A keyed **one-shot** re-requested while it runs must still get its run.
2690	///
2691	/// The in-place metadata update is right — a second concurrent body would be
2692	/// two VACUUMs for `core.db_maintenance:manual` — but "the run in flight
2693	/// reschedules itself from the new parameters" only holds for a *cron* task.
2694	/// A one-shot takes the `store.finished` branch instead, so without the rerun
2695	/// flag the run the caller asked for is silently dropped.
2696	#[tokio::test]
2697	pub async fn a_running_keyed_one_shot_re_requested_in_flight_runs_again() {
2698		let _ = tracing_subscriber::fmt().try_init();
2699
2700		let store = KeyedTaskStore::new();
2701		let task_store: Arc<dyn TaskStore<State>> = store.clone();
2702		let state: State = Arc::new(Mutex::new(Vec::new()));
2703		let scheduler = Scheduler::new(task_store);
2704		scheduler.start(state.clone());
2705		scheduler.register::<GatedTask>().unwrap();
2706
2707		let gated = Gated::new();
2708		let first_id = scheduler.task(gated.task(1, false)).key("test.gated").now().await.unwrap();
2709		gated.await_run().await;
2710
2711		// Same key, new parameters, while the first body is still held open.
2712		let second_id = scheduler.task(gated.task(2, false)).key("test.gated").now().await.unwrap();
2713		assert_eq!(second_id, first_id, "the key must resolve to the running task");
2714		assert_eq!(gated.params(), vec![1], "no second body may start alongside the first");
2715
2716		// Let the first run finish; the finish handler owes the caller a re-run.
2717		gated.gate.add_permits(1);
2718		gated.await_run().await;
2719		gated.gate.add_permits(1);
2720
2721		assert_eq!(
2722			gated.params(),
2723			vec![1, 2],
2724			"the re-requested run must happen, with the new parameters"
2725		);
2726
2727		// And it must be *accounted for*: the re-requested run has to reach
2728		// `finished`, or the persisted row stays `status='P'` forever — re-run on
2729		// every process restart, dependents never released.
2730		//
2731		// Polled rather than asserted straight away: the second body's completion
2732		// travels through the finish channel.
2733		for _ in 0..200 {
2734			if !store.finished_ids().is_empty() {
2735				break;
2736			}
2737			tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2738		}
2739		assert_eq!(
2740			store.finished_ids(),
2741			vec![first_id],
2742			"the re-requested run must reach `finished`, or the task is stuck pending"
2743		);
2744		assert!(
2745			!scheduler.tasks_running.lock().unwrap().contains_key(&first_id),
2746			"a finished task must not stay in the running map"
2747		);
2748	}
2749
2750	/// The same, for a re-request whose parameters are **identical**.
2751	///
2752	/// This is the common shape, not the exotic one: `IndexDocumentTask { tn_id,
2753	/// file_id }` serializes the same way for every edit of the same file, so a
2754	/// second edit landing while the first index run is in flight takes the
2755	/// identical-parameters branch. Falling through to `add_queue` there absorbs
2756	/// the request — its already-running arm only *copies* an existing rerun flag
2757	/// and never sets one — leaving the document's index stale until the weekly
2758	/// sweep.
2759	#[tokio::test]
2760	pub async fn a_running_keyed_one_shot_re_requested_with_identical_params_runs_again() {
2761		let _ = tracing_subscriber::fmt().try_init();
2762
2763		let store = KeyedTaskStore::new();
2764		let task_store: Arc<dyn TaskStore<State>> = store.clone();
2765		let state: State = Arc::new(Mutex::new(Vec::new()));
2766		let scheduler = Scheduler::new(task_store);
2767		scheduler.start(state.clone());
2768		scheduler.register::<GatedTask>().unwrap();
2769
2770		let gated = Gated::new();
2771		let first_id = scheduler.task(gated.task(1, false)).key("test.gated").now().await.unwrap();
2772		gated.await_run().await;
2773
2774		// Same key, *same* parameters, while the first body is still held open.
2775		let second_id = scheduler.task(gated.task(1, false)).key("test.gated").now().await.unwrap();
2776		assert_eq!(second_id, first_id, "the key must resolve to the running task");
2777		assert_eq!(gated.params(), vec![1], "no second body may start alongside the first");
2778
2779		gated.gate.add_permits(1);
2780		gated.await_run().await;
2781		gated.gate.add_permits(1);
2782
2783		assert_eq!(
2784			gated.params(),
2785			vec![1, 1],
2786			"an identical re-request carries the same intent as a changed one"
2787		);
2788
2789		for _ in 0..200 {
2790			if !store.finished_ids().is_empty() {
2791				break;
2792			}
2793			tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2794		}
2795		assert_eq!(
2796			store.finished_ids(),
2797			vec![first_id],
2798			"the re-requested run must reach `finished`, or the task is stuck pending"
2799		);
2800	}
2801
2802	/// A cron task with no retry policy must keep its row Pending when a run
2803	/// fails.
2804	///
2805	/// `update_task_error(id, err, None)` maps to `mark_error`'s `status='E'`
2806	/// arm. In memory the task is fine — the finish handler sees the cron and
2807	/// reschedules — but the reschedule persists through `update`, which has no
2808	/// status clause and never restores `'P'`. From then on `find_by_key` and
2809	/// `load()` (both `status='P'`-filtered) cannot see the row, the next boot
2810	/// mints a duplicate, and `run_on_startup` fires again: a full `VACUUM` of
2811	/// `meta.db` on every restart.
2812	#[tokio::test]
2813	pub async fn a_failing_cron_run_keeps_the_row_pending() {
2814		let _ = tracing_subscriber::fmt().try_init();
2815
2816		let store = KeyedTaskStore::new();
2817		let task_store: Arc<dyn TaskStore<State>> = store.clone();
2818		let state: State = Arc::new(Mutex::new(Vec::new()));
2819		let scheduler = Scheduler::new(task_store);
2820		scheduler.start(state.clone());
2821		scheduler.register::<GatedTask>().unwrap();
2822
2823		let gated = Gated::new();
2824		// No retry policy, so the failure is terminal for this occurrence.
2825		let id = scheduler
2826			.task(gated.task(1, true))
2827			.key("test.cron.failing")
2828			.cron("*/5 * * * *")
2829			.run_on_startup()
2830			.schedule()
2831			.await
2832			.unwrap();
2833		gated.await_run().await;
2834
2835		// Let the body fail.
2836		gated.gate.add_permits(1);
2837		for _ in 0..200 {
2838			if !store.error_calls().is_empty() {
2839				break;
2840			}
2841			tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2842		}
2843
2844		let errors = store.error_calls();
2845		assert_eq!(errors.len(), 1, "the failed run must be persisted");
2846		assert_eq!(errors[0].0, id);
2847		assert!(
2848			errors[0].1.is_some(),
2849			"a cron task's row must keep a live next_at, not be stamped 'E'"
2850		);
2851		assert!(store.finished_ids().is_empty(), "a failed run does not finish the task");
2852	}
2853
2854	/// A retry after an in-flight update must carry the **new** parameters.
2855	///
2856	/// `spawn_task` captures its `TaskMeta` when the run starts, so building
2857	/// `retry_meta` from that snapshot would re-run the old parameters while the
2858	/// persisted row already holds the new ones.
2859	#[tokio::test]
2860	pub async fn a_retry_after_an_in_flight_update_uses_the_new_parameters() {
2861		let _ = tracing_subscriber::fmt().try_init();
2862
2863		let task_store: Arc<dyn TaskStore<State>> = KeyedTaskStore::new();
2864		let state: State = Arc::new(Mutex::new(Vec::new()));
2865		let scheduler = Scheduler::new(task_store);
2866		scheduler.start(state.clone());
2867		scheduler.register::<GatedTask>().unwrap();
2868
2869		let gated = Gated::new();
2870		// Zero backoff, so the retry is re-queued for "now" and the scheduler's
2871		// notify path picks it up without any timer.
2872		let first_id = scheduler
2873			.task(gated.task(1, true))
2874			.key("test.gated")
2875			.with_retry(RetryPolicy::new((0, 0), 3))
2876			.now()
2877			.await
2878			.unwrap();
2879		gated.await_run().await;
2880
2881		let second_id = scheduler
2882			.task(gated.task(2, false))
2883			.key("test.gated")
2884			.with_retry(RetryPolicy::new((0, 0), 3))
2885			.now()
2886			.await
2887			.unwrap();
2888		assert_eq!(second_id, first_id);
2889
2890		// Release the first body; it fails retryably and the retry is queued.
2891		gated.gate.add_permits(1);
2892		gated.await_run().await;
2893		gated.gate.add_permits(1);
2894
2895		assert_eq!(
2896			gated.params(),
2897			vec![1, 2],
2898			"the retry must run the parameters the in-flight update wrote"
2899		);
2900	}
2901
2902	/// A re-request that lands **while `store.finished` is in flight** must still
2903	/// run.
2904	///
2905	/// The window: were the finish handler to read the rerun flag and only then
2906	/// await `store.finished` with the id still in `tasks_running`, a request
2907	/// arriving during that await would be absorbed by `schedule_task_impl`'s
2908	/// in-place update and thrown away when the handler removed the entry — and
2909	/// the leftover flag would make the *next* run of that id re-queue itself once
2910	/// more. Reachable at `worker_threads = 1` precisely because `store.finished`
2911	/// is an await; taking the entry out *before* the await is what closes it.
2912	///
2913	/// Driven by gates, not timing: with one worker every step below is forced,
2914	/// because awaiting an already-ready future does not yield. The flavour is
2915	/// pinned rather than left to `#[tokio::test]`'s default — on a multi-thread
2916	/// runtime the window this test exists to cover stops existing, and the test
2917	/// would pass while asserting nothing.
2918	#[tokio::test(flavor = "current_thread")]
2919	pub async fn a_re_request_landing_while_the_finish_handler_marks_finished_still_runs() {
2920		let _ = tracing_subscriber::fmt().try_init();
2921
2922		let store = KeyedTaskStore::new_gated_finished();
2923		let task_store: Arc<dyn TaskStore<State>> = store.clone();
2924		let state: State = Arc::new(Mutex::new(Vec::new()));
2925		let scheduler = Scheduler::new(task_store);
2926		scheduler.start(state.clone());
2927		scheduler.register::<GatedTask>().unwrap();
2928
2929		let gated = Gated::new();
2930		// `add`/`find_by_key` are ungated, but the first schedule's `update_task`
2931		// is not reached (no existing row), so nothing parks here.
2932		let id = scheduler.task(gated.task(1, false)).key("test.gated").now().await.unwrap();
2933		gated.await_run().await;
2934
2935		// Body 1 returns; the handler wakes, sees no cron and no rerun, takes the
2936		// id out of `tasks_running`, then enters `store.finished` and parks.
2937		gated.gate.add_permits(1);
2938		store.await_finished_entered().await;
2939
2940		// The re-request lands inside that await.
2941		let second = scheduler.task(gated.task(2, false)).key("test.gated").now().await.unwrap();
2942		assert_eq!(second, id, "the key must resolve to the same task");
2943
2944		// Release `finished`. The requested run must now happen.
2945		store.release_finished(8);
2946		gated.await_run().await;
2947		gated.gate.add_permits(1);
2948
2949		assert_eq!(
2950			gated.params(),
2951			vec![1, 2],
2952			"the request that landed during `finished` must still run, with the new parameters"
2953		);
2954
2955		for _ in 0..200 {
2956			if store.finished_ids().len() >= 2 {
2957				break;
2958			}
2959			tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2960		}
2961		// Not `== vec![id]`: in this interleaving the row is legitimately marked
2962		// finished twice — the second run queued itself through the ordinary path
2963		// while the first `finished` was still in flight. That residual is
2964		// documented at the handler's terminal branch; closing it needs a
2965		// store-level re-open.
2966		assert!(
2967			store.finished_ids().contains(&id),
2968			"the re-requested run must reach `finished`, or the row stays 'P' forever"
2969		);
2970		assert!(
2971			!scheduler.tasks_running.lock().unwrap().contains_key(&id),
2972			"a finished task must not stay in the running map"
2973		);
2974	}
2975
2976	/// A cron task's reschedule must use the metadata an in-flight update wrote,
2977	/// not the snapshot the handler took before awaiting `store.update_task`.
2978	///
2979	/// Building `updated_meta` from a clone taken before the await discards a
2980	/// re-request landing during it twice over: the entry is deleted and the stale
2981	/// parameters re-queued. Reachable at one worker.
2982	#[tokio::test]
2983	pub async fn a_cron_reschedule_uses_the_parameters_an_in_flight_update_wrote() {
2984		let _ = tracing_subscriber::fmt().try_init();
2985
2986		let store = KeyedTaskStore::new_gated_update();
2987		let task_store: Arc<dyn TaskStore<State>> = store.clone();
2988		let state: State = Arc::new(Mutex::new(Vec::new()));
2989		let scheduler = Scheduler::new(task_store);
2990		scheduler.start(state.clone());
2991		scheduler.register::<GatedTask>().unwrap();
2992
2993		let gated = Gated::new();
2994		let id = scheduler
2995			.task(gated.task(1, false))
2996			.key("test.cron")
2997			.cron("*/5 * * * *")
2998			.run_on_startup()
2999			.schedule()
3000			.await
3001			.unwrap();
3002		gated.await_run().await;
3003
3004		// Body 1 returns; the handler takes the cron branch and parks in
3005		// `store.update_task`.
3006		gated.gate.add_permits(1);
3007		store.await_updated_entered().await;
3008
3009		// The re-request lands inside that await — and parks on the same gate on
3010		// its own way in, so the release has to come from beside it. `notify_one`
3011		// stores a permit when nobody is waiting, so this releaser cannot miss the
3012		// second entry however the two are interleaved.
3013		let releaser = {
3014			let store = Arc::clone(&store);
3015			tokio::spawn(async move {
3016				store.await_updated_entered().await;
3017				store.release_updates(8);
3018			})
3019		};
3020		let second = scheduler
3021			.task(gated.task(2, false))
3022			.key("test.cron")
3023			.cron("*/5 * * * *")
3024			.schedule()
3025			.await
3026			.unwrap();
3027		assert_eq!(second, id, "the key must resolve to the running task");
3028		releaser.await.unwrap();
3029		// The reschedule is white-box and instant — no wall-clock wait, the next
3030		// cron firing is minutes away.
3031		for _ in 0..200 {
3032			let queued = {
3033				let scheduled = scheduler.tasks_scheduled.lock().unwrap();
3034				scheduled.iter().any(|((_, tid), _)| *tid == id)
3035			};
3036			if queued {
3037				break;
3038			}
3039			tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3040		}
3041		let scheduled = scheduler.tasks_scheduled.lock().unwrap();
3042		let (_, meta) = scheduled
3043			.iter()
3044			.find(|((_, tid), _)| *tid == id)
3045			.expect("cron task must be rescheduled");
3046		assert_eq!(meta.task.serialize(), "2", "the cron reschedule used the stale snapshot");
3047	}
3048}
3049
3050// vim: ts=4