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 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 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 let file = File::create(output)?;
176 let mut writer = BufWriter::new(file);
177
178 match format {
179 "csv" => {
180 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 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 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 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_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)); 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}