Skip to main content

cargo_hammerwork/commands/
backup.rs

1use anyhow::Result;
2use clap::Subcommand;
3use serde_json::Value;
4use sqlx::Row;
5use std::fs::File;
6use std::io::{BufWriter, Write};
7use tracing::info;
8
9use crate::config::Config;
10use crate::utils::database::DatabasePool;
11
12#[derive(Subcommand)]
13pub enum BackupCommand {
14    #[command(about = "Create a backup of job data")]
15    Create {
16        #[arg(short = 'u', long, help = "Database connection URL")]
17        database_url: Option<String>,
18        #[arg(short = 'o', long, help = "Output file path")]
19        output: String,
20        #[arg(short = 'n', long, help = "Include only specific queue")]
21        queue: Option<String>,
22        #[arg(long, help = "Include completed jobs")]
23        include_completed: bool,
24        #[arg(long, help = "Include failed jobs")]
25        include_failed: bool,
26        #[arg(long, help = "Backup format (json, csv)")]
27        format: Option<String>,
28    },
29    #[command(about = "Restore job data from backup")]
30    Restore {
31        #[arg(short = 'u', long, help = "Database connection URL")]
32        database_url: Option<String>,
33        #[arg(short = 'i', long, help = "Input file path")]
34        input: String,
35        #[arg(long, help = "Confirm the restore operation")]
36        confirm: bool,
37        #[arg(long, help = "Skip existing jobs")]
38        skip_existing: bool,
39    },
40    #[command(about = "List available backups")]
41    List {
42        #[arg(short = 'p', long, help = "Backup directory path")]
43        path: Option<String>,
44    },
45}
46
47#[derive(serde::Serialize, serde::Deserialize, Debug)]
48struct JobData {
49    id: String,
50    queue_name: String,
51    payload: Value,
52    status: String,
53    priority: String,
54    attempts: i32,
55    max_attempts: i32,
56    created_at: chrono::DateTime<chrono::Utc>,
57    scheduled_at: chrono::DateTime<chrono::Utc>,
58    started_at: Option<chrono::DateTime<chrono::Utc>>,
59    completed_at: Option<chrono::DateTime<chrono::Utc>>,
60    failed_at: Option<chrono::DateTime<chrono::Utc>>,
61    error_message: Option<String>,
62}
63
64impl BackupCommand {
65    pub async fn execute(&self, config: &Config) -> Result<()> {
66        match self {
67            BackupCommand::Create {
68                database_url,
69                output,
70                queue,
71                include_completed,
72                include_failed,
73                format,
74            } => {
75                let db_url = database_url
76                    .as_ref()
77                    .map(|s| s.as_str())
78                    .or(config.get_database_url())
79                    .ok_or_else(|| anyhow::anyhow!("Database URL is required"))?;
80
81                create_backup(
82                    db_url,
83                    output,
84                    queue.clone(),
85                    *include_completed,
86                    *include_failed,
87                    format.as_deref().unwrap_or("json"),
88                    config.get_connection_pool_size(),
89                )
90                .await?;
91            }
92            BackupCommand::Restore {
93                database_url,
94                input,
95                confirm,
96                skip_existing,
97            } => {
98                let db_url = database_url
99                    .as_ref()
100                    .map(|s| s.as_str())
101                    .or(config.get_database_url())
102                    .ok_or_else(|| anyhow::anyhow!("Database URL is required"))?;
103
104                restore_backup(
105                    db_url,
106                    input,
107                    *confirm,
108                    *skip_existing,
109                    config.get_connection_pool_size(),
110                )
111                .await?;
112            }
113            BackupCommand::List { path } => {
114                list_backups(path.clone()).await?;
115            }
116        }
117        Ok(())
118    }
119}
120
121async fn create_backup(
122    database_url: &str,
123    output: &str,
124    queue: Option<String>,
125    include_completed: bool,
126    include_failed: bool,
127    format: &str,
128    pool_size: u32,
129) -> Result<()> {
130    let pool = DatabasePool::connect(database_url, pool_size).await?;
131
132    // Build query based on filters
133    let mut query = "SELECT id, queue_name, payload, status, priority, attempts, max_attempts, created_at, scheduled_at, started_at, completed_at, failed_at, error_message FROM hammerwork_jobs WHERE 1=1".to_string();
134    let mut conditions = Vec::new();
135
136    if let Some(queue_name) = &queue {
137        conditions.push(format!("queue_name = '{}'", queue_name));
138    }
139
140    if !include_completed {
141        conditions.push("status != 'Completed'".to_string());
142    }
143
144    if !include_failed {
145        conditions.push("status != 'Failed'".to_string());
146    }
147
148    if !conditions.is_empty() {
149        query.push_str(&format!(" AND {}", conditions.join(" AND ")));
150    }
151
152    query.push_str(" ORDER BY created_at ASC");
153
154    info!("Creating backup with query: {}", query);
155
156    // Execute query and extract data based on database type
157    let job_data = match &pool {
158        DatabasePool::Postgres(pg_pool) => {
159            let rows = sqlx::query(&query).fetch_all(pg_pool).await?;
160            rows.into_iter()
161                .map(|row| extract_job_data_postgres(&row))
162                .collect::<Result<Vec<_>>>()?
163        }
164        DatabasePool::MySQL(mysql_pool) => {
165            let rows = sqlx::query(&query).fetch_all(mysql_pool).await?;
166            rows.into_iter()
167                .map(|row| extract_job_data_mysql(&row))
168                .collect::<Result<Vec<_>>>()?
169        }
170    };
171
172    info!("Found {} jobs to backup", job_data.len());
173
174    // Create output file
175    let file = File::create(output)?;
176    let mut writer = BufWriter::new(file);
177
178    match format {
179        "csv" => {
180            // CSV format
181            writeln!(
182                writer,
183                "id,queue_name,payload,status,priority,attempts,max_attempts,created_at,scheduled_at,started_at,completed_at,failed_at,error_message"
184            )?;
185
186            for job in &job_data {
187                writeln!(
188                    writer,
189                    "{},{},{},{},{},{},{},{},{},{},{},{},{}",
190                    job.id,
191                    job.queue_name,
192                    job.payload.to_string().replace(',', ";"),
193                    job.status,
194                    job.priority,
195                    job.attempts,
196                    job.max_attempts,
197                    job.created_at.to_rfc3339(),
198                    job.scheduled_at.to_rfc3339(),
199                    job.started_at.map(|t| t.to_rfc3339()).unwrap_or_default(),
200                    job.completed_at.map(|t| t.to_rfc3339()).unwrap_or_default(),
201                    job.failed_at.map(|t| t.to_rfc3339()).unwrap_or_default(),
202                    job.error_message.as_deref().unwrap_or("").replace(',', ";")
203                )?;
204            }
205        }
206        _ => {
207            // JSON format (default)
208            let backup_data = serde_json::json!({
209                "version": "1.0",
210                "created_at": chrono::Utc::now(),
211                "total_jobs": job_data.len(),
212                "filters": {
213                    "queue": queue,
214                    "include_completed": include_completed,
215                    "include_failed": include_failed
216                },
217                "jobs": job_data
218            });
219
220            serde_json::to_writer_pretty(&mut writer, &backup_data)?;
221        }
222    }
223
224    writer.flush()?;
225    info!("✅ Backup created successfully: {}", output);
226    println!("💾 Backup saved to: {}", output);
227    println!("📊 Total jobs backed up: {}", job_data.len());
228
229    Ok(())
230}
231
232fn extract_job_data_postgres(row: &sqlx::postgres::PgRow) -> Result<JobData> {
233    Ok(JobData {
234        id: row.try_get("id")?,
235        queue_name: row.try_get("queue_name")?,
236        payload: row.try_get("payload")?,
237        status: row.try_get("status")?,
238        priority: row.try_get("priority")?,
239        attempts: row.try_get("attempts")?,
240        max_attempts: row.try_get("max_attempts")?,
241        created_at: row.try_get("created_at")?,
242        scheduled_at: row.try_get("scheduled_at")?,
243        started_at: row.try_get("started_at")?,
244        completed_at: row.try_get("completed_at")?,
245        failed_at: row.try_get("failed_at")?,
246        error_message: row.try_get("error_message")?,
247    })
248}
249
250fn extract_job_data_mysql(row: &sqlx::mysql::MySqlRow) -> Result<JobData> {
251    Ok(JobData {
252        id: row.try_get("id")?,
253        queue_name: row.try_get("queue_name")?,
254        payload: row.try_get("payload")?,
255        status: row.try_get("status")?,
256        priority: row.try_get("priority")?,
257        attempts: row.try_get("attempts")?,
258        max_attempts: row.try_get("max_attempts")?,
259        created_at: row.try_get("created_at")?,
260        scheduled_at: row.try_get("scheduled_at")?,
261        started_at: row.try_get("started_at")?,
262        completed_at: row.try_get("completed_at")?,
263        failed_at: row.try_get("failed_at")?,
264        error_message: row.try_get("error_message")?,
265    })
266}
267
268async fn restore_backup(
269    database_url: &str,
270    input: &str,
271    confirm: bool,
272    skip_existing: bool,
273    pool_size: u32,
274) -> Result<()> {
275    if !confirm {
276        println!("⚠️  This will restore jobs from backup. Use --confirm to proceed.");
277        return Ok(());
278    }
279
280    let pool = DatabasePool::connect(database_url, pool_size).await?;
281
282    // Read backup file
283    let backup_content = std::fs::read_to_string(input)?;
284    let backup_data: Value = serde_json::from_str(&backup_content)?;
285
286    let jobs = backup_data["jobs"]
287        .as_array()
288        .ok_or_else(|| anyhow::anyhow!("Invalid backup format: missing jobs array"))?;
289
290    info!("Restoring {} jobs from backup", jobs.len());
291
292    let mut restored = 0;
293    let mut skipped = 0;
294
295    for job in jobs {
296        let id = job["id"]
297            .as_str()
298            .ok_or_else(|| anyhow::anyhow!("Invalid job: missing id"))?;
299
300        // Check if job already exists
301        if skip_existing {
302            let exists = check_job_exists(&pool, id).await?;
303            if exists {
304                skipped += 1;
305                continue;
306            }
307        }
308
309        // Insert job
310        insert_job_from_backup(&pool, job).await?;
311        restored += 1;
312    }
313
314    info!(
315        "✅ Restore completed: {} jobs restored, {} skipped",
316        restored, skipped
317    );
318    println!("📥 Restore completed successfully");
319    println!("   Restored: {} jobs", restored);
320    if skipped > 0 {
321        println!("   Skipped: {} existing jobs", skipped);
322    }
323
324    Ok(())
325}
326
327async fn check_job_exists(pool: &DatabasePool, id: &str) -> Result<bool> {
328    match pool {
329        DatabasePool::Postgres(pg_pool) => {
330            let result = sqlx::query("SELECT 1 FROM hammerwork_jobs WHERE id = $1")
331                .bind(id)
332                .fetch_optional(pg_pool)
333                .await?;
334            Ok(result.is_some())
335        }
336        DatabasePool::MySQL(mysql_pool) => {
337            let result = sqlx::query("SELECT 1 FROM hammerwork_jobs WHERE id = ?")
338                .bind(id)
339                .fetch_optional(mysql_pool)
340                .await?;
341            Ok(result.is_some())
342        }
343    }
344}
345
346async fn insert_job_from_backup(pool: &DatabasePool, job: &Value) -> Result<()> {
347    let id = job["id"].as_str().unwrap_or("");
348    let queue_name = job["queue_name"].as_str().unwrap_or("");
349    let payload = &job["payload"];
350    let status = job["status"].as_str().unwrap_or("Pending");
351    let priority = job["priority"].as_str().unwrap_or("normal");
352    let attempts = job["attempts"].as_i64().unwrap_or(0) as i32;
353    let max_attempts = job["max_attempts"].as_i64().unwrap_or(3) as i32;
354
355    let created_at = job["created_at"]
356        .as_str()
357        .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
358        .map(|dt| dt.with_timezone(&chrono::Utc))
359        .unwrap_or_else(chrono::Utc::now);
360
361    let scheduled_at = job["scheduled_at"]
362        .as_str()
363        .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
364        .map(|dt| dt.with_timezone(&chrono::Utc))
365        .unwrap_or_else(chrono::Utc::now);
366
367    match pool {
368        DatabasePool::Postgres(pg_pool) => {
369            sqlx::query(
370                r#"
371                INSERT INTO hammerwork_jobs (
372                    id, queue_name, payload, status, priority, attempts, max_attempts,
373                    created_at, scheduled_at
374                ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
375                ON CONFLICT (id) DO NOTHING
376            "#,
377            )
378            .bind(id)
379            .bind(queue_name)
380            .bind(payload)
381            .bind(status)
382            .bind(priority)
383            .bind(attempts)
384            .bind(max_attempts)
385            .bind(created_at)
386            .bind(scheduled_at)
387            .execute(pg_pool)
388            .await?;
389        }
390        DatabasePool::MySQL(mysql_pool) => {
391            sqlx::query(
392                r#"
393                INSERT IGNORE INTO hammerwork_jobs (
394                    id, queue_name, payload, status, priority, attempts, max_attempts,
395                    created_at, scheduled_at
396                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
397            "#,
398            )
399            .bind(id)
400            .bind(queue_name)
401            .bind(payload)
402            .bind(status)
403            .bind(priority)
404            .bind(attempts)
405            .bind(max_attempts)
406            .bind(created_at)
407            .bind(scheduled_at)
408            .execute(mysql_pool)
409            .await?;
410        }
411    }
412
413    Ok(())
414}
415
416async fn list_backups(path: Option<String>) -> Result<()> {
417    let backup_dir = path.unwrap_or_else(|| "./backups".to_string());
418
419    if !std::path::Path::new(&backup_dir).exists() {
420        println!("📂 No backup directory found at: {}", backup_dir);
421        return Ok(());
422    }
423
424    let entries = std::fs::read_dir(&backup_dir)?;
425    let mut backups = Vec::new();
426
427    for entry in entries {
428        let entry = entry?;
429        let path = entry.path();
430
431        if path.is_file() {
432            if let Some(ext) = path.extension() {
433                if ext == "json" || ext == "csv" {
434                    let metadata = entry.metadata()?;
435                    let size = metadata.len();
436                    let modified = metadata.modified()?;
437                    let modified_time = chrono::DateTime::<chrono::Utc>::from(modified);
438
439                    backups.push((
440                        path.file_name().unwrap().to_string_lossy().to_string(),
441                        size,
442                        modified_time,
443                    ));
444                }
445            }
446        }
447    }
448
449    if backups.is_empty() {
450        println!("📂 No backups found in: {}", backup_dir);
451        return Ok(());
452    }
453
454    backups.sort_by(|a, b| b.2.cmp(&a.2)); // Sort by modified time, newest first
455
456    println!("📋 Available Backups");
457    println!("════════════════════");
458
459    for (name, size, modified) in backups {
460        let size_str = if size > 1024 * 1024 {
461            format!("{:.1} MB", size as f64 / (1024.0 * 1024.0))
462        } else if size > 1024 {
463            format!("{:.1} KB", size as f64 / 1024.0)
464        } else {
465            format!("{} bytes", size)
466        };
467
468        println!(
469            "📄 {} ({}) - {}",
470            name,
471            size_str,
472            modified.format("%Y-%m-%d %H:%M:%S UTC")
473        );
474    }
475
476    Ok(())
477}