Skip to main content

snerd_rust/
queue.rs

1use chrono::Utc;
2use std::collections::{HashMap, HashSet, BinaryHeap};
3use tokio::sync::Semaphore;
4use std::sync::{Arc, Mutex};
5use std::time::Duration;
6use tokio::sync::RwLock;
7use serde_json::json;
8
9use crate::file_store::FileStore;
10use crate::rate_limiter::RateLimiter;
11use crate::task::{RetryableTask, PriorityTask, ProgressMessage};
12use tokio::sync::broadcast;
13
14pub type TaskHandler = Arc<dyn Fn(String) -> Result<(), String> + Send + Sync>;
15pub type MaxRetryHandler = Arc<dyn Fn(String) -> Result<(), String> + Send + Sync>;
16
17struct ExecutingGuard {
18    executing_tasks: Arc<Mutex<HashSet<String>>>,
19    task_id: String,
20}
21
22impl Drop for ExecutingGuard {
23    fn drop(&mut self) {
24        if let Ok(mut executing) = self.executing_tasks.lock() {
25            executing.remove(&self.task_id);
26        }
27    }
28}
29
30#[derive(Clone)]
31pub struct SnerdQueue {
32    pub name: String,
33    pub file_store: FileStore,
34    pub rate_limiter: RateLimiter,
35    task_handlers: Arc<RwLock<HashMap<String, TaskHandler>>>,
36    max_retry_handlers: Arc<RwLock<HashMap<String, MaxRetryHandler>>>,
37    active_hashes: Arc<Mutex<HashSet<String>>>,
38    executing_tasks: Arc<Mutex<HashSet<String>>>,
39    /// Tasks that have been pushed to shared_pq but haven't started executing yet.
40    /// Prevents process_due_tasks() from re-adding the same task to the queue.
41    queued_tasks: Arc<Mutex<HashSet<String>>>,
42    /// Tasks that have completed execution (successfully or max retries reached).
43    /// Final safety net to prevent duplicate execution.
44    completed_tasks: Arc<Mutex<HashSet<String>>>,
45    worker_semaphore: Arc<Semaphore>,
46    pub progress_tx: broadcast::Sender<ProgressMessage>,
47    /// Shared priority queue — workers always pop the highest-priority task next.
48    shared_pq: Arc<Mutex<BinaryHeap<PriorityTask>>>,
49    /// Number of active dispatcher loops (prevents duplicates).
50    dispatcher_count: Arc<std::sync::atomic::AtomicUsize>,
51}
52
53impl SnerdQueue {
54    pub fn new(name: &str, file_store: FileStore, rate_limiter: RateLimiter) -> Self {
55        let mut initial_hashes = HashSet::new();
56        if let Ok(tasks) = file_store.read_tasks() {
57            for task in tasks {
58                if task.deleted_at.is_none() {
59                    if let Some(hash) = task.payload_hash {
60                        initial_hashes.insert(hash);
61                    }
62                }
63            }
64        }
65
66        let (progress_tx, _) = broadcast::channel(1024);
67        Self {
68            name: name.to_string(),
69            file_store,
70            rate_limiter,
71            task_handlers: Arc::new(RwLock::new(HashMap::new())),
72            max_retry_handlers: Arc::new(RwLock::new(HashMap::new())),
73            active_hashes: Arc::new(Mutex::new(initial_hashes)),
74            executing_tasks: Arc::new(Mutex::new(HashSet::new())),
75            queued_tasks: Arc::new(Mutex::new(HashSet::new())),
76            completed_tasks: Arc::new(Mutex::new(HashSet::new())),
77            worker_semaphore: Arc::new(Semaphore::new(100)), // Limit to 100 concurrent tasks
78            progress_tx,
79            shared_pq: Arc::new(Mutex::new(BinaryHeap::new())),
80            dispatcher_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
81        }
82    }
83
84    pub fn subscribe_progress(&self) -> broadcast::Receiver<ProgressMessage> {
85        self.progress_tx.subscribe()
86    }
87
88    pub fn yield_progress(&self, task_id: &str, data: &str) {
89        let _ = self.progress_tx.send(ProgressMessage {
90            task_id: task_id.to_string(),
91            data: data.to_string(),
92        });
93    }
94
95    pub async fn register_task_handler<F>(&self, task_type: &str, handler: F)
96    where
97        F: Fn(String) -> Result<(), String> + Send + Sync + 'static,
98    {
99        self.task_handlers
100            .write()
101            .await
102            .insert(task_type.to_string(), Arc::new(handler));
103    }
104
105    pub async fn register_max_retry_handler<F>(&self, task_type: &str, handler: F)
106    where
107        F: Fn(String) -> Result<(), String> + Send + Sync + 'static,
108    {
109        self.max_retry_handlers
110            .write()
111            .await
112            .insert(task_type.to_string(), Arc::new(handler));
113    }
114
115    pub fn enqueue(&self, mut task: RetryableTask) -> std::io::Result<()> {
116        if let Some(ref hash) = task.payload_hash {
117            if let Ok(mut hashes) = self.active_hashes.lock() {
118                if hashes.contains(hash) {
119                    return Ok(());
120                }
121                hashes.insert(hash.clone());
122            }
123        }
124        task.deleted_at = None;
125        self.file_store.save_task(&task)?;
126
127        // NOTE: We intentionally do NOT execute tasks immediately here.
128        // All execution goes through the periodic processor (process_due_tasks)
129        // which uses a BinaryHeap to respect priority ordering.
130        // The fast path would bypass priority and cause low-priority tasks
131        // enqueued first to always execute before high-priority tasks enqueued later.
132
133        Ok(())
134    }
135
136    pub async fn start_processor(&self, interval: Duration) {
137        let q = self.clone();
138        tokio::spawn(async move {
139            let mut interval_timer = tokio::time::interval(interval);
140            loop {
141                interval_timer.tick().await;
142                q.process_due_tasks().await;
143            }
144        });
145    }
146
147    pub async fn process_due_tasks(&self) {
148        let tasks = match self.file_store.read_tasks() {
149            Ok(t) => t,
150            Err(_) => return,
151        };
152
153        let now = Utc::now();
154
155        // IMPORTANT: Check against LIVE executing_tasks and queued_tasks sets
156        // (not snapshots) to prevent races where a task moves from queued → executing
157        // between our snapshot and our check, making it invisible to both.
158        {
159            let mut pq = self.shared_pq.lock().unwrap();
160            let mut queued = self.queued_tasks.lock().unwrap();
161            let executing = self.executing_tasks.lock().unwrap();
162            for task in tasks {
163                if task.execute_at <= now
164                    && task.retry_after_time <= now
165                    && task.deleted_at.is_none()
166                    && !executing.contains(&task.task_id)
167                    && !queued.contains(&task.task_id)
168                {
169                    queued.insert(task.task_id.clone());
170                    pq.push(PriorityTask(task));
171                }
172            }
173        }
174
175        // Start a priority dispatcher if there are tasks queued and not too many dispatchers
176        let pq_len = self.shared_pq.lock().unwrap().len();
177        if pq_len > 0 && self.dispatcher_count.load(std::sync::atomic::Ordering::Relaxed) < 2 {
178            self.spawn_dispatcher();
179        }
180    }
181
182    /// Spawns a persistent priority dispatcher that feeds tasks to workers
183    /// in strict priority order. The dispatcher acquires a semaphore permit
184    /// for each task, ensuring at most 100 concurrent executions. When a task
185    /// completes and releases its permit, the dispatcher wakes up and spawns
186    /// the next highest-priority task.
187    fn spawn_dispatcher(&self) {
188        self.dispatcher_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
189        let q = self.clone();
190        tokio::spawn(async move {
191            loop {
192                // Acquire a concurrency permit (blocks if all 100 are in use)
193                let permit = match q.worker_semaphore.clone().acquire_owned().await {
194                    Ok(p) => p,
195                    Err(_) => break,
196                };
197
198                // Pop the highest-priority task from the shared queue
199                let task = {
200                    let mut pq = q.shared_pq.lock().unwrap();
201                    pq.pop()
202                };
203
204                match task {
205                    Some(PriorityTask(mut task)) => {
206                        // Rate limit check
207                        if let Some(ref group) = task.rate_limit_group {
208                            if let Some(limit) = task.max_per_minute {
209                                match q.rate_limiter.check_and_increment(group, limit) {
210                                    Ok(true) => {}
211                                    Ok(false) | Err(_) => {
212                                        task.retry_after_time = Utc::now() + chrono::Duration::seconds(60);
213                                        let _ = q.file_store.save_task(&task);
214                                        // Remove from queued so it can be re-queued after rate limit window
215                                        q.queued_tasks.lock().unwrap().remove(&task.task_id);
216                                        drop(permit);
217                                        continue;
218                                    }
219                                }
220                            }
221                        }
222
223                        // Move from queued to executing
224                        {
225                            let mut queued = q.queued_tasks.lock().unwrap();
226                            queued.remove(&task.task_id);
227                            let mut executing = q.executing_tasks.lock().unwrap();
228                            if executing.contains(&task.task_id) {
229                                drop(permit);
230                                continue;
231                            }
232                            executing.insert(task.task_id.clone());
233                        }
234
235                        let q2 = q.clone();
236                        tokio::spawn(async move {
237                            let _permit = permit;
238                            q2.execute_task(task).await;
239                        });
240                    }
241                    None => {
242                        drop(permit);
243                        break; // Queue empty, dispatcher exits
244                    }
245                }
246            }
247            q.dispatcher_count.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
248        });
249    }
250
251    async fn execute_task(&self, mut task: RetryableTask) {
252        // Final safety check: skip if already completed (prevents duplicate execution)
253        {
254            let completed = self.completed_tasks.lock().unwrap();
255            if completed.contains(&task.task_id) {
256                return; // Already completed, skip
257            }
258        }
259
260        // Drop guard guarantees removal from executing_tasks
261        let _guard = ExecutingGuard {
262            executing_tasks: Arc::clone(&self.executing_tasks),
263            task_id: task.task_id.clone(),
264        };
265
266        // Build the execution result — either via webhook HTTP call or local handler
267        let result: Result<(), String> = if let Some(ref url) = task.webhook_url.clone() {
268            // --- Webhook Path ---
269            let payload = json!({
270                "taskId": task.task_id,
271                "taskType": task.task_type,
272                "data": task.task_data,
273            });
274            let url = url.clone();
275            tokio::task::spawn_blocking(move || {
276                let rt = tokio::runtime::Handle::current();
277                rt.block_on(async {
278                    let mut client_builder = reqwest::Client::builder();
279                    if let Some(secs) = task.max_execution_seconds {
280                        client_builder = client_builder.timeout(std::time::Duration::from_secs(secs));
281                    }
282                    let client = client_builder.build().unwrap_or_else(|_| reqwest::Client::new());
283                    
284                    match client
285                        .post(&url)
286                        .header("Content-Type", "application/json")
287                        .header("X-SnerdMQ-Event", "Execute")
288                        .json(&payload)
289                        .send()
290                        .await
291                    {
292                        Ok(resp) if resp.status().is_success() => Ok(()),
293                        Ok(resp) => Err(format!("Webhook returned non-2xx status: {}", resp.status())),
294                        Err(e) => {
295                            if e.is_timeout() {
296                                Err(format!("Webhook execution timed out after {} seconds", task.max_execution_seconds.unwrap_or(0)))
297                            } else {
298                                Err(format!("Webhook request failed: {}", e))
299                            }
300                        }
301                    }
302                })
303            })
304            .await
305            .unwrap_or_else(|e| Err(format!("Webhook task panic: {:?}", e)))
306        } else {
307            // --- Local Handler Path ---
308            let handler = {
309                let handlers = self.task_handlers.read().await;
310                handlers.get(&task.task_type).cloned()
311            };
312            if let Some(h) = handler {
313                let task_data = task.task_data.clone();
314                let fut = tokio::task::spawn_blocking(move || h(task_data));
315                
316                if let Some(secs) = task.max_execution_seconds {
317                    match tokio::time::timeout(std::time::Duration::from_secs(secs), fut).await {
318                        Ok(Ok(res)) => res,
319                        Ok(Err(e)) => Err(format!("Task panic: {:?}", e)),
320                        Err(_) => Err(format!("Task execution timed out after {} seconds", secs)),
321                    }
322                } else {
323                    fut.await.unwrap_or_else(|e| Err(format!("Task panic: {:?}", e)))
324                }
325            } else {
326                return; // No handler and no webhook — nothing to do
327            }
328        };
329
330        match result {
331            Ok(_) => {
332                let mut rescheduled = false;
333                if let Some(ref cron_expr) = task.cron_expression {
334                    use cron::Schedule;
335                    use std::str::FromStr;
336                    if let Ok(schedule) = Schedule::from_str(cron_expr) {
337                        if let Some(next) = schedule.upcoming(Utc).next() {
338                            task.execute_at = next;
339                            task.retry_count = 0;
340                            task.last_error_obj = None;
341                            task.last_job_error = None;
342                            let _ = self.file_store.save_task(&task);
343                            rescheduled = true;
344                        }
345                    }
346                }
347
348                if !rescheduled {
349                    // Mark as completed to prevent duplicate execution
350                    self.completed_tasks.lock().unwrap().insert(task.task_id.clone());
351                    let _ = self.file_store.delete_task(&task.task_id);
352                    if let Some(ref hash) = task.payload_hash {
353                        if let Ok(mut hashes) = self.active_hashes.lock() {
354                            hashes.remove(hash);
355                        }
356                    }
357                }
358            }
359            Err(e) => {
360                // max_retries means total attempts (not retries after first).
361                // retry_count starts at 0 and update_retry_config increments it AFTER this check.
362                // So we allow retry while retry_count < max_retries - 1.
363                if task.retry_count < task.max_retries - 1 {
364                    task.update_retry_config(Some(e));
365                    let _ = self.file_store.save_task(&task);
366                } else {
367                    // Max retries reached — fire DLQ webhook or local max retry handler
368                    if let Some(ref url) = task.webhook_url.clone() {
369                        let payload = json!({
370                            "taskId": task.task_id,
371                            "taskType": task.task_type,
372                            "data": task.task_data,
373                        });
374                        let url = url.clone();
375                        tokio::spawn(async move {
376                            let _ = reqwest::Client::new()
377                                .post(&url)
378                                .header("Content-Type", "application/json")
379                                .header("X-SnerdMQ-Event", "MaxRetriesReached")
380                                .json(&payload)
381                                .send()
382                                .await;
383                        });
384                    } else {
385                        let max_handler = {
386                            let max_handlers = self.max_retry_handlers.read().await;
387                            max_handlers.get(&task.task_type).cloned()
388                        };
389                        if let Some(mh) = max_handler {
390                            // Pass full task info as JSON to DLQ handler
391                            let dlq_payload = serde_json::to_string(&json!({
392                                "taskId": task.task_id,
393                                "taskType": task.task_type,
394                                "data": task.task_data,
395                            })).unwrap_or_else(|_| task.task_data.clone());
396                            let _ = tokio::task::spawn_blocking(move || mh(dlq_payload)).await;
397                        }
398                    }
399
400                    // Mark as completed to prevent duplicate execution
401                    self.completed_tasks.lock().unwrap().insert(task.task_id.clone());
402                    let _ = self.file_store.delete_task(&task.task_id);
403                    if let Some(ref hash) = task.payload_hash {
404                        if let Ok(mut hashes) = self.active_hashes.lock() {
405                            hashes.remove(hash);
406                        }
407                    }
408                }
409            }
410        }
411    }
412}