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 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 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 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 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 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 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 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 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 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}