High-performance implementation of plcbundle written in Rust
at main 454 lines 14 kB view raw
1use super::utils; 2use anyhow::{Result, bail}; 3use clap::Args; 4use plcbundle::BundleManager; 5use std::io::{self, Write}; 6use std::path::{Path, PathBuf}; 7 8#[derive(Args)] 9#[command( 10 about = "Rollback repository to earlier state", 11 long_about = "Remove bundles from the end of the chain to restore the repository to an 12earlier state. This is useful for undoing recent syncs, removing corrupted 13bundles, or reverting to a known-good state. 14 15You can rollback to a specific bundle number using --to (keeps that bundle 16and all earlier ones), or remove the last N bundles using --last. The command 17shows a detailed plan before execution, including which bundles will be deleted, 18how much data will be removed, and what additional impacts there are (like 19mempool clearing or DID index invalidation). 20 21By default, bundle files are permanently deleted. Use --keep-files to update 22the index only while leaving bundle files on disk. Use --rebuild-did-index 23to automatically rebuild the DID index after rollback. 24 25This operation cannot be undone, so use with caution. The command requires 26explicit confirmation by typing 'rollback' unless --force is used.", 27 help_template = crate::clap_help!( 28 examples: " # Rollback TO bundle 100 (keeps 1-100, removes 101+)\n \ 29 {bin} rollback --to 100\n\n \ 30 # Remove last 5 bundles\n \ 31 {bin} rollback --last 5\n\n \ 32 # Rollback without confirmation\n \ 33 {bin} rollback --to 50 -f\n\n \ 34 # Rollback and rebuild DID index\n \ 35 {bin} rollback --to 100 --rebuild-did-index\n\n \ 36 # Rollback but keep bundle files (index-only)\n \ 37 {bin} rollback --to 100 --keep-files" 38 ) 39)] 40pub struct RollbackCommand { 41 /// Rollback TO this bundle (keeps it) 42 #[arg(long)] 43 pub to: Option<u32>, 44 45 /// Rollback last N bundles 46 #[arg(long)] 47 pub last: Option<u32>, 48 49 /// Skip confirmation prompt 50 #[arg(short, long)] 51 pub force: bool, 52 53 /// Rebuild DID index after rollback 54 #[arg(long)] 55 pub rebuild_did_index: bool, 56 57 /// Update index only (don't delete bundle files) 58 #[arg(long)] 59 pub keep_files: bool, 60} 61 62#[derive(Debug)] 63struct RollbackPlan { 64 target_bundle: u32, 65 bundles_to_keep: usize, 66 bundles_to_delete: Vec<u32>, 67 deleted_ops: usize, 68 deleted_size: u64, 69 has_mempool: bool, 70 has_did_index: bool, 71 affected_period: Option<(String, String)>, 72} 73 74pub fn run(cmd: RollbackCommand, dir: PathBuf, global_verbose: bool) -> Result<()> { 75 // Step 1: Validate options and calculate plan 76 let mut manager = super::utils::create_manager(dir.clone(), global_verbose, false, false)?; 77 let plan = calculate_rollback_plan(&manager, &cmd)?; 78 79 // Step 2: Display plan and get confirmation 80 display_rollback_plan(&dir, &plan)?; 81 82 if !cmd.force { 83 if !confirm_rollback(cmd.keep_files)? { 84 println!("Cancelled"); 85 return Ok(()); 86 } 87 println!(); 88 } 89 90 // Step 3: Execute rollback 91 perform_rollback(&mut manager, &dir, &plan, &cmd, global_verbose)?; 92 93 // Step 4: Display success summary 94 display_rollback_success(&plan, &cmd)?; 95 96 Ok(()) 97} 98 99fn calculate_rollback_plan(manager: &BundleManager, cmd: &RollbackCommand) -> Result<RollbackPlan> { 100 // Validate options 101 if cmd.to.is_none() && cmd.last.is_none() { 102 bail!("either --to or --last must be specified"); 103 } 104 105 if cmd.to.is_some() && cmd.last.is_some() { 106 bail!("cannot use both --to and --last together"); 107 } 108 109 if super::utils::is_repository_empty(manager) { 110 bail!("no bundles to rollback"); 111 } 112 113 let last_bundle = manager.get_last_bundle(); 114 115 // Calculate target bundle 116 let target_bundle = if let Some(to) = cmd.to { 117 to 118 } else if let Some(last) = cmd.last { 119 if last >= last_bundle { 120 bail!( 121 "cannot rollback {} bundles, only {} exist", 122 last, 123 last_bundle 124 ); 125 } 126 let calculated = last_bundle - last; 127 128 // Prevent accidental deletion of all bundles via --last 129 if calculated == 0 { 130 bail!("invalid rollback: would delete all bundles (use --to 0 explicitly if intended)"); 131 } 132 133 calculated 134 } else { 135 unreachable!() 136 }; 137 138 // Validate target 139 if target_bundle >= last_bundle { 140 bail!("already at bundle {} (nothing to rollback)", last_bundle); 141 } 142 143 // Build list of bundles to delete 144 let bundles_to_delete: Vec<u32> = (target_bundle + 1..=last_bundle).collect(); 145 146 if bundles_to_delete.is_empty() { 147 bail!("already at bundle {} (nothing to rollback)", target_bundle); 148 } 149 150 // Calculate statistics 151 let mut deleted_ops = 0; 152 let mut deleted_size = 0u64; 153 let mut start_time = None; 154 let mut end_time = None; 155 156 for bundle_num in &bundles_to_delete { 157 if let Some(meta) = manager.get_bundle_metadata(*bundle_num)? { 158 deleted_ops += meta.operation_count as usize; 159 deleted_size += meta.compressed_size; 160 161 if start_time.is_none() { 162 start_time = Some(meta.start_time.clone()); 163 } 164 end_time = Some(meta.end_time.clone()); 165 } 166 } 167 168 let affected_period = if let (Some(start), Some(end)) = (start_time, end_time) { 169 Some((start, end)) 170 } else { 171 None 172 }; 173 174 // Check mempool and DID index 175 let mempool_stats = manager.get_mempool_stats()?; 176 let has_mempool = mempool_stats.count > 0; 177 178 let did_stats = manager.get_did_index_stats(); 179 let has_did_index = did_stats 180 .get("total_dids") 181 .and_then(|v| v.as_i64()) 182 .unwrap_or(0) 183 > 0; 184 185 Ok(RollbackPlan { 186 target_bundle, 187 bundles_to_keep: target_bundle as usize, 188 bundles_to_delete, 189 deleted_ops, 190 deleted_size, 191 has_mempool, 192 has_did_index, 193 affected_period, 194 }) 195} 196 197fn display_rollback_plan(dir: &Path, plan: &RollbackPlan) -> Result<()> { 198 println!("╔════════════════════════════════════════════════════════════════╗"); 199 println!("║ ROLLBACK PLAN ║"); 200 println!("╚════════════════════════════════════════════════════════════════╝\n"); 201 202 println!("📁 Repository"); 203 println!(" Directory: {}", utils::display_path(dir).display()); 204 205 let current_bundles = plan.bundles_to_keep + plan.bundles_to_delete.len(); 206 if current_bundles > 0 { 207 let last = plan.bundles_to_delete.last().unwrap(); 208 println!( 209 " Current state: {} bundles ({}{})", 210 current_bundles, 1, last 211 ); 212 } 213 println!(" Target: bundle {}\n", plan.target_bundle); 214 215 println!("🗑️ Will Delete"); 216 println!(" Bundles: {}", plan.bundles_to_delete.len()); 217 println!( 218 " Operations: {}", 219 super::utils::format_number(plan.deleted_ops as u64) 220 ); 221 println!( 222 " Data size: {}", 223 super::utils::format_bytes(plan.deleted_size) 224 ); 225 226 if let Some((start, end)) = &plan.affected_period { 227 let start_dt = chrono::DateTime::parse_from_rfc3339(start) 228 .unwrap_or_else(|_| chrono::Utc::now().into()); 229 let end_dt = 230 chrono::DateTime::parse_from_rfc3339(end).unwrap_or_else(|_| chrono::Utc::now().into()); 231 println!( 232 " Time period: {} to {}", 233 start_dt.format("%Y-%m-%d %H:%M"), 234 end_dt.format("%Y-%m-%d %H:%M") 235 ); 236 } 237 println!(); 238 239 // Show sample of deleted bundles 240 if !plan.bundles_to_delete.is_empty() { 241 println!(" Bundles to delete:"); 242 let display_count = std::cmp::min(10, plan.bundles_to_delete.len()); 243 for &bundle_num in &plan.bundles_to_delete[..display_count] { 244 println!("{}", bundle_num); 245 } 246 if plan.bundles_to_delete.len() > display_count { 247 println!( 248 " ... and {} more", 249 plan.bundles_to_delete.len() - display_count 250 ); 251 } 252 println!(); 253 } 254 255 // Show impacts 256 println!("⚠️ Additional Impacts"); 257 if plan.has_mempool { 258 println!(" • Mempool will be cleared"); 259 } 260 if plan.has_did_index { 261 println!(" • DID index will need rebuilding"); 262 } 263 if plan.bundles_to_keep == 0 { 264 println!(" • Repository will be EMPTY after rollback"); 265 } 266 println!(); 267 268 Ok(()) 269} 270 271fn confirm_rollback(keep_files: bool) -> Result<bool> { 272 if keep_files { 273 print!("Type 'rollback-index' to confirm (index-only mode): "); 274 } else { 275 println!("⚠️ This will permanently DELETE data!"); 276 print!("Type 'rollback' to confirm: "); 277 } 278 279 io::stdout().flush()?; 280 281 let mut response = String::new(); 282 io::stdin().read_line(&mut response)?; 283 284 let expected = if keep_files { 285 "rollback-index" 286 } else { 287 "rollback" 288 }; 289 290 Ok(response.trim() == expected) 291} 292 293fn perform_rollback( 294 manager: &mut BundleManager, 295 _dir: &PathBuf, 296 plan: &RollbackPlan, 297 cmd: &RollbackCommand, 298 _verbose: bool, 299) -> Result<()> { 300 let total_steps = 4; 301 let mut current_step = 0; 302 303 // Step 1: Delete bundle files (or skip if keep_files) 304 current_step += 1; 305 if !cmd.keep_files { 306 println!( 307 "[{}/{}] Deleting bundle files...", 308 current_step, total_steps 309 ); 310 delete_bundle_files(manager, &plan.bundles_to_delete)?; 311 println!(" ✓ Deleted {} file(s)\n", plan.bundles_to_delete.len()); 312 } else { 313 println!( 314 "[{}/{}] Skipping file deletion (--keep-files)...", 315 current_step, total_steps 316 ); 317 println!(" ℹ Bundle files remain on disk\n"); 318 } 319 320 // Step 2: Clear mempool 321 current_step += 1; 322 println!("[{}/{}] Clearing mempool...", current_step, total_steps); 323 if plan.has_mempool { 324 manager.clear_mempool()?; 325 println!(" ✓ Mempool cleared\n"); 326 } else { 327 println!(" (no mempool data)\n"); 328 } 329 330 // Step 3: Update index 331 current_step += 1; 332 println!( 333 "[{}/{}] Updating bundle index...", 334 current_step, total_steps 335 ); 336 manager.rollback_to_bundle(plan.target_bundle)?; 337 println!(" ✓ Index updated ({} bundles)\n", plan.bundles_to_keep); 338 339 // Step 4: Handle DID index 340 current_step += 1; 341 println!("[{}/{}] DID index...", current_step, total_steps); 342 handle_did_index(manager, plan, cmd)?; 343 344 Ok(()) 345} 346 347fn delete_bundle_files(manager: &BundleManager, bundles: &[u32]) -> Result<()> { 348 let stats = manager.delete_bundle_files(bundles)?; 349 350 if stats.failed > 0 { 351 eprintln!(" ⚠️ Failed to delete {} file(s)", stats.failed); 352 bail!("Failed to delete {} bundle files", stats.failed); 353 } 354 355 Ok(()) 356} 357 358fn handle_did_index( 359 manager: &mut BundleManager, 360 plan: &RollbackPlan, 361 cmd: &RollbackCommand, 362) -> Result<()> { 363 if !plan.has_did_index { 364 println!(" (no DID index)"); 365 return Ok(()); 366 } 367 368 if cmd.rebuild_did_index { 369 println!(" Rebuilding DID index..."); 370 371 if plan.bundles_to_keep == 0 { 372 println!(" ℹ No bundles to index"); 373 return Ok(()); 374 } 375 376 // Use default flush interval for rollback 377 let _stats = manager.build_did_index( 378 crate::constants::DID_INDEX_FLUSH_INTERVAL, 379 None::<fn(u32, u32, u64, u64)>, 380 None, 381 None, 382 )?; 383 println!( 384 " ✓ DID index rebuilt ({} bundles)", 385 plan.bundles_to_keep 386 ); 387 } else { 388 let did_stats = manager.get_did_index_stats(); 389 println!(" ⚠️ DID index is out of date"); 390 if did_stats 391 .get("total_entries") 392 .and_then(|v| v.as_i64()) 393 .unwrap_or(0) 394 > 0 395 { 396 println!( 397 " Run: {} index rebuild", 398 crate::constants::BINARY_NAME 399 ); 400 } 401 } 402 403 Ok(()) 404} 405 406fn display_rollback_success(plan: &RollbackPlan, cmd: &RollbackCommand) -> Result<()> { 407 println!(); 408 println!("╔════════════════════════════════════════════════════════════════╗"); 409 println!("║ ROLLBACK COMPLETE ║"); 410 println!("╚════════════════════════════════════════════════════════════════╝\n"); 411 412 if plan.bundles_to_keep > 0 { 413 println!("📦 New State"); 414 println!( 415 " Bundles: {} ({}{})", 416 plan.bundles_to_keep, 1, plan.target_bundle 417 ); 418 } else { 419 println!("📦 New State"); 420 println!(" Repository: EMPTY (all bundles removed)"); 421 if cmd.keep_files { 422 println!(" Note: Bundle files remain on disk"); 423 } 424 } 425 426 println!(); 427 428 // Show what was removed 429 println!("🗑️ Removed"); 430 println!(" Bundles: {}", plan.bundles_to_delete.len()); 431 println!( 432 " Operations: {}", 433 super::utils::format_number(plan.deleted_ops as u64) 434 ); 435 println!( 436 " Data freed: {}", 437 super::utils::format_bytes(plan.deleted_size) 438 ); 439 440 if cmd.keep_files { 441 println!(" Files: kept on disk"); 442 } 443 444 println!(); 445 446 // Next steps 447 if !cmd.rebuild_did_index && plan.has_did_index { 448 println!("💡 Next Steps"); 449 println!(" DID index is out of date. Rebuild with:"); 450 println!(" {} index rebuild\n", crate::constants::BINARY_NAME); 451 } 452 453 Ok(()) 454}