Skip to main content

cargo_hammerwork/commands/
batch.rs

1use anyhow::Result;
2use clap::Subcommand;
3use serde_json::Value;
4use std::fs::File;
5use std::io::{BufRead, BufReader};
6use tracing::info;
7
8use crate::config::Config;
9use crate::utils::database::DatabasePool;
10use crate::utils::db_helpers::*;
11
12#[derive(Subcommand)]
13pub enum BatchCommand {
14    #[command(about = "Enqueue multiple jobs from a file or stdin")]
15    Enqueue {
16        #[arg(short = 'u', long, help = "Database connection URL")]
17        database_url: Option<String>,
18        #[arg(short = 'f', long, help = "Input file path (JSON lines format)")]
19        file: Option<String>,
20        #[arg(short = 'n', long, help = "Default queue name")]
21        queue: String,
22        #[arg(short = 'r', long, help = "Default priority")]
23        priority: Option<String>,
24        #[arg(long, help = "Batch size for bulk inserts")]
25        batch_size: Option<u32>,
26        #[arg(long, help = "Continue on errors")]
27        continue_on_error: bool,
28    },
29    #[command(about = "Retry multiple jobs by criteria")]
30    Retry {
31        #[arg(short = 'u', long, help = "Database connection URL")]
32        database_url: Option<String>,
33        #[arg(short = 'n', long, help = "Queue name filter")]
34        queue: Option<String>,
35        #[arg(short = 't', long, help = "Status filter (failed, dead)")]
36        status: Option<String>,
37        #[arg(long, help = "Hours since last failure")]
38        failed_since_hours: Option<u32>,
39        #[arg(long, help = "Maximum attempts filter")]
40        max_attempts_reached: bool,
41        #[arg(long, help = "Confirm the batch retry operation")]
42        confirm: bool,
43        #[arg(long, help = "Dry run - show what would be retried")]
44        dry_run: bool,
45    },
46    #[command(about = "Cancel multiple jobs by criteria")]
47    Cancel {
48        #[arg(short = 'u', long, help = "Database connection URL")]
49        database_url: Option<String>,
50        #[arg(short = 'n', long, help = "Queue name filter")]
51        queue: Option<String>,
52        #[arg(short = 't', long, help = "Status filter (pending, running)")]
53        status: Option<String>,
54        #[arg(long, help = "Jobs older than N hours")]
55        older_than_hours: Option<u32>,
56        #[arg(long, help = "Confirm the batch cancel operation")]
57        confirm: bool,
58        #[arg(long, help = "Dry run - show what would be cancelled")]
59        dry_run: bool,
60    },
61    #[command(about = "Export job data to CSV/JSON")]
62    Export {
63        #[arg(short = 'u', long, help = "Database connection URL")]
64        database_url: Option<String>,
65        #[arg(short = 'o', long, help = "Output file path")]
66        output: String,
67        #[arg(short = 'n', long, help = "Queue name filter")]
68        queue: Option<String>,
69        #[arg(short = 't', long, help = "Status filter")]
70        status: Option<String>,
71        #[arg(long, help = "Export format (csv, json, jsonl)")]
72        format: Option<String>,
73        #[arg(long, help = "Include job payload in export")]
74        include_payload: bool,
75        #[arg(long, help = "Maximum number of jobs to export")]
76        limit: Option<u32>,
77    },
78}
79
80impl BatchCommand {
81    pub async fn execute(&self, config: &Config) -> Result<()> {
82        let db_url = self.get_database_url(config)?;
83        let pool = DatabasePool::connect(&db_url, config.get_connection_pool_size()).await?;
84
85        match self {
86            BatchCommand::Enqueue {
87                file,
88                queue,
89                priority,
90                batch_size,
91                continue_on_error,
92                ..
93            } => {
94                batch_enqueue(
95                    pool,
96                    file.clone(),
97                    queue,
98                    priority.clone(),
99                    batch_size.unwrap_or(100),
100                    *continue_on_error,
101                )
102                .await?;
103            }
104            BatchCommand::Retry {
105                queue,
106                status,
107                failed_since_hours,
108                max_attempts_reached,
109                confirm,
110                dry_run,
111                ..
112            } => {
113                batch_retry(
114                    pool,
115                    queue.clone(),
116                    status.clone(),
117                    *failed_since_hours,
118                    *max_attempts_reached,
119                    *confirm,
120                    *dry_run,
121                )
122                .await?;
123            }
124            BatchCommand::Cancel {
125                queue,
126                status,
127                older_than_hours,
128                confirm,
129                dry_run,
130                ..
131            } => {
132                batch_cancel(
133                    pool,
134                    queue.clone(),
135                    status.clone(),
136                    *older_than_hours,
137                    *confirm,
138                    *dry_run,
139                )
140                .await?;
141            }
142            BatchCommand::Export {
143                output,
144                queue,
145                status,
146                format,
147                include_payload,
148                limit,
149                ..
150            } => {
151                println!("šŸ“¤ Export functionality coming soon!");
152                println!("   Output: {}", output);
153                if let Some(q) = queue {
154                    println!("   Queue filter: {}", q);
155                }
156                if let Some(s) = status {
157                    println!("   Status filter: {}", s);
158                }
159                if let Some(f) = format {
160                    println!("   Format: {}", f);
161                }
162                println!("   Include payload: {}", include_payload);
163                if let Some(l) = limit {
164                    println!("   Limit: {}", l);
165                }
166            }
167        }
168        Ok(())
169    }
170
171    fn get_database_url(&self, config: &Config) -> Result<String> {
172        let url = match self {
173            BatchCommand::Enqueue { database_url, .. } => database_url,
174            BatchCommand::Retry { database_url, .. } => database_url,
175            BatchCommand::Cancel { database_url, .. } => database_url,
176            BatchCommand::Export { database_url, .. } => database_url,
177        };
178
179        url.as_ref()
180            .map(|s| s.as_str())
181            .or(config.get_database_url())
182            .ok_or_else(|| anyhow::anyhow!("Database URL is required"))
183            .map(|s| s.to_string())
184    }
185}
186
187async fn batch_enqueue(
188    pool: DatabasePool,
189    file: Option<String>,
190    default_queue: &str,
191    default_priority: Option<String>,
192    batch_size: u32,
193    continue_on_error: bool,
194) -> Result<()> {
195    info!("Starting batch enqueue operation");
196
197    let reader: Box<dyn BufRead> = if let Some(file_path) = file {
198        Box::new(BufReader::new(File::open(file_path)?))
199    } else {
200        println!("šŸ“„ Reading from stdin (provide JSON lines format)...");
201        Box::new(BufReader::new(std::io::stdin()))
202    };
203
204    let mut total_processed = 0;
205    let mut total_errors = 0;
206
207    for (line_num, line) in reader.lines().enumerate() {
208        let line = line?;
209        if line.trim().is_empty() {
210            continue;
211        }
212
213        // Parse JSON line
214        let job_data: Value = match serde_json::from_str(&line) {
215            Ok(data) => data,
216            Err(e) => {
217                total_errors += 1;
218                eprintln!("āŒ Line {}: Invalid JSON - {}", line_num + 1, e);
219                if !continue_on_error {
220                    return Err(anyhow::anyhow!(
221                        "JSON parsing failed at line {}",
222                        line_num + 1
223                    ));
224                }
225                continue;
226            }
227        };
228
229        // Extract job fields with defaults
230        let queue = job_data["queue"]
231            .as_str()
232            .unwrap_or(default_queue)
233            .to_string();
234        let payload = job_data["payload"].clone();
235        let priority = job_data["priority"]
236            .as_str()
237            .or(default_priority.as_deref())
238            .unwrap_or("normal")
239            .to_string();
240
241        // Insert single job (simplified)
242        let result = insert_single_job(&pool, &queue, &payload, &priority).await;
243        match result {
244            Ok(_) => {
245                total_processed += 1;
246                if total_processed % batch_size == 0 {
247                    println!("āœ… Processed {} jobs", total_processed);
248                }
249            }
250            Err(e) => {
251                total_errors += 1;
252                eprintln!("āŒ Job insert failed: {}", e);
253                if !continue_on_error {
254                    return Err(e);
255                }
256            }
257        }
258    }
259
260    println!("šŸ“Š Batch Enqueue Summary");
261    println!("════════════════════════");
262    println!("Total jobs processed: {}", total_processed);
263    if total_errors > 0 {
264        println!("Total errors: {}", total_errors);
265    }
266
267    info!(
268        "Batch enqueue completed: {} processed, {} errors",
269        total_processed, total_errors
270    );
271    Ok(())
272}
273
274async fn insert_single_job(
275    pool: &DatabasePool,
276    queue: &str,
277    payload: &Value,
278    priority: &str,
279) -> Result<()> {
280    let job_id = uuid::Uuid::new_v4().to_string();
281    let now = chrono::Utc::now();
282
283    match pool {
284        DatabasePool::Postgres(pg_pool) => {
285            sqlx::query(
286                r#"
287                INSERT INTO hammerwork_jobs (
288                    id, queue_name, payload, status, priority, attempts, max_attempts,
289                    created_at, scheduled_at
290                ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
291            "#,
292            )
293            .bind(&job_id)
294            .bind(queue)
295            .bind(payload)
296            .bind("Pending")
297            .bind(priority)
298            .bind(0)
299            .bind(3)
300            .bind(now)
301            .bind(now)
302            .execute(pg_pool)
303            .await?;
304        }
305        DatabasePool::MySQL(mysql_pool) => {
306            sqlx::query(
307                r#"
308                INSERT INTO hammerwork_jobs (
309                    id, queue_name, payload, status, priority, attempts, max_attempts,
310                    created_at, scheduled_at
311                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
312            "#,
313            )
314            .bind(&job_id)
315            .bind(queue)
316            .bind(payload)
317            .bind("Pending")
318            .bind(priority)
319            .bind(0)
320            .bind(3)
321            .bind(now)
322            .bind(now)
323            .execute(mysql_pool)
324            .await?;
325        }
326    }
327
328    Ok(())
329}
330
331async fn batch_retry(
332    pool: DatabasePool,
333    queue: Option<String>,
334    status: Option<String>,
335    failed_since_hours: Option<u32>,
336    max_attempts_reached: bool,
337    confirm: bool,
338    dry_run: bool,
339) -> Result<()> {
340    if !confirm && !dry_run {
341        println!(
342            "āš ļø  This will retry multiple jobs. Use --confirm to proceed or --dry-run to preview."
343        );
344        return Ok(());
345    }
346
347    // Build filter conditions
348    let mut conditions = Vec::new();
349
350    if let Some(queue_name) = &queue {
351        conditions.push(format!("queue_name = '{}'", queue_name));
352    }
353
354    match status.as_deref() {
355        Some("failed") => conditions.push("status = 'Failed'".to_string()),
356        Some("dead") => conditions.push("status = 'Dead'".to_string()),
357        None => conditions.push("status IN ('Failed', 'Dead')".to_string()),
358        _ => {
359            return Err(anyhow::anyhow!(
360                "Invalid status filter. Use 'failed' or 'dead'"
361            ));
362        }
363    }
364
365    if let Some(hours) = failed_since_hours {
366        let cutoff = chrono::Utc::now() - chrono::Duration::hours(hours as i64);
367        conditions.push(format!(
368            "failed_at > '{}'",
369            cutoff.format("%Y-%m-%d %H:%M:%S")
370        ));
371    }
372
373    if max_attempts_reached {
374        conditions.push("attempts >= max_attempts".to_string());
375    }
376
377    let where_clause = if conditions.is_empty() {
378        "1=1".to_string()
379    } else {
380        conditions.join(" AND ")
381    };
382
383    // Count jobs to retry
384    let count_query = format!(
385        "SELECT COUNT(*) as count FROM hammerwork_jobs WHERE {}",
386        where_clause
387    );
388    let job_count = execute_count_query(&pool, &count_query).await?;
389
390    println!("šŸ”„ Batch Retry Analysis");
391    println!("═══════════════════════");
392    println!("Jobs matching criteria: {}", job_count);
393    if let Some(q) = &queue {
394        println!("Queue filter: {}", q);
395    }
396    if let Some(s) = &status {
397        println!("Status filter: {}", s);
398    }
399
400    if dry_run {
401        println!("\nšŸ’” This was a dry run. Use --confirm to actually retry these jobs.");
402        return Ok(());
403    }
404
405    if job_count == 0 {
406        println!("✨ No jobs found matching the criteria.");
407        return Ok(());
408    }
409
410    info!("Retrying {} jobs", job_count);
411
412    // Update jobs to retry
413    let update_query = format!(
414        "UPDATE hammerwork_jobs SET status = 'Pending', attempts = 0, scheduled_at = '{}', error_message = NULL WHERE {}",
415        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"),
416        where_clause
417    );
418
419    let updated = execute_update_query(&pool, &update_query).await?;
420
421    println!("āœ… Batch retry completed");
422    println!("   Retried {} jobs", updated);
423
424    Ok(())
425}
426
427async fn batch_cancel(
428    pool: DatabasePool,
429    queue: Option<String>,
430    status: Option<String>,
431    older_than_hours: Option<u32>,
432    confirm: bool,
433    dry_run: bool,
434) -> Result<()> {
435    if !confirm && !dry_run {
436        println!(
437            "āš ļø  This will cancel multiple jobs. Use --confirm to proceed or --dry-run to preview."
438        );
439        return Ok(());
440    }
441
442    // Build filter conditions
443    let mut conditions = Vec::new();
444
445    if let Some(queue_name) = &queue {
446        conditions.push(format!("queue_name = '{}'", queue_name));
447    }
448
449    match status.as_deref() {
450        Some("pending") => conditions.push("status = 'Pending'".to_string()),
451        Some("running") => conditions.push("status = 'Running'".to_string()),
452        None => conditions.push("status IN ('Pending', 'Running')".to_string()),
453        _ => {
454            return Err(anyhow::anyhow!(
455                "Invalid status filter. Use 'pending' or 'running'"
456            ));
457        }
458    }
459
460    if let Some(hours) = older_than_hours {
461        let cutoff = chrono::Utc::now() - chrono::Duration::hours(hours as i64);
462        conditions.push(format!(
463            "created_at < '{}'",
464            cutoff.format("%Y-%m-%d %H:%M:%S")
465        ));
466    }
467
468    let where_clause = if conditions.is_empty() {
469        "1=1".to_string()
470    } else {
471        conditions.join(" AND ")
472    };
473
474    // Count jobs to cancel
475    let count_query = format!(
476        "SELECT COUNT(*) as count FROM hammerwork_jobs WHERE {}",
477        where_clause
478    );
479    let job_count = execute_count_query(&pool, &count_query).await?;
480
481    println!("🚫 Batch Cancel Analysis");
482    println!("═══════════════════════");
483    println!("Jobs matching criteria: {}", job_count);
484
485    if dry_run {
486        println!("\nšŸ’” This was a dry run. Use --confirm to actually cancel these jobs.");
487        return Ok(());
488    }
489
490    if job_count == 0 {
491        println!("✨ No jobs found matching the criteria.");
492        return Ok(());
493    }
494
495    info!("Cancelling {} jobs", job_count);
496
497    // Delete jobs
498    let delete_query = format!("DELETE FROM hammerwork_jobs WHERE {}", where_clause);
499    let deleted = execute_update_query(&pool, &delete_query).await?;
500
501    println!("āœ… Batch cancel completed");
502    println!("   Cancelled {} jobs", deleted);
503
504    Ok(())
505}