diff --git a/bedrock/src/migration/README.md b/bedrock/src/migration/README.md new file mode 100644 index 00000000..f3e25bc4 --- /dev/null +++ b/bedrock/src/migration/README.md @@ -0,0 +1,50 @@ +## Migration Controller +The Migration `controller.rs` is a simple state machine that runs a for loop over a series of processors and executes the processors. The processors contain logic around performing an individual migration and conform to a simple interface: + +```rust +trait Process { + /// Determines whether the migration should run. + fn is_applicable(&self) -> bool; + + /// Business logic that performs the migration. + fn execute(&self) -> Result<(), MigrationError>; +} +``` + +The migration system is a permanent artifact of the app and is run on app start to bring the app to a expected state. The processors are expected to be idempotent. + +## States +The `controller.rs` stores a key value mapping between the id of the migration and a record of the migration. The record most importantly contains the status of the migration, but also useful monitoring and debug information such as `started_at`, `last_attempted_at`. + +The possible states are: +- `NotStarted` - migration has not been performed +- `InProgress` - migration started, but was interrupted +- `Succeeded` - migration successfully completed +- `FailedRetryable` - migration failed, but can be retried (e.g. there was a network error) +- `FailedTerminal` - migration failed and represents a terminal state. It can not be retried. + +The migration state storage optimizes subsequent app starts by skipping `Succeeded` and `FailedTerminal` migrations without calling `process.is_applicable()`. For `NotStarted` and `FailedRetryable` migrations, `process.is_applicable()` is called each time to detect when they become applicable. This ensures migrations can respond to changing app state. + +## State transitions +1. `NotStarted` + - → `InProgress` when `is_applicable()` returns true and migration execution begins + - Remains `NotStarted` if `is_applicable()` returns false (will be checked again on next app start) + - → `FailedRetryable` if `is_applicable()` fails or times out + +2. `InProgress` + - → `Succeeded` when `execute()` completes successfully + - → `FailedRetryable` if `execute()` times out or fails with retryable error + - → `FailedTerminal` if `execute()` fails with terminal error + - → `FailedRetryable` if detected as stale (app crashed mid-migration) + +3. `Succeeded` + - Terminal state. No further transitions. Migration is skipped on subsequent runs. + +4. `FailedRetryable` + - → `InProgress` when retry is attempted (after exponential backoff period) + - → `Succeeded` if retry succeeds + - → `FailedTerminal` if retry fails with terminal error + - Remains `FailedRetryable` if retry fails again with retryable error (backoff period increases) + +5. `FailedTerminal` + - Terminal state. No further transitions. Migration is permanently skipped on subsequent runs. diff --git a/bedrock/src/migration/controller.rs b/bedrock/src/migration/controller.rs index 6e540818..63696427 100644 --- a/bedrock/src/migration/controller.rs +++ b/bedrock/src/migration/controller.rs @@ -12,6 +12,12 @@ const MIGRATION_KEY_PREFIX: &str = "migration:"; const DEFAULT_RETRY_DELAY_MS: i64 = 60_000; // 1 minute const MAX_RETRY_DELAY_MS: i64 = 86_400_000; // 1 day +// Consider InProgress migrations stale after this duration (indicates crash/abandon) +#[cfg(not(test))] +const STALE_IN_PROGRESS_MINS: i64 = 5; // 5 minutes in production +#[cfg(test)] +const STALE_IN_PROGRESS_MINS: i64 = 0; // Immediately stale in tests (for testing) + /// Global lock to prevent concurrent migration runs across all controller instances. /// This is a process-wide coordination mechanism that ensures only one migration /// can execute at a time, regardless of how many [`MigrationController`] instances exist. @@ -34,8 +40,6 @@ pub struct MigrationRunSummary { pub failed_retryable: i32, /// Number of migrations that failed with terminal errors (won't retry) pub failed_terminal: i32, - /// Number of migrations blocked pending user action - pub blocked: i32, /// Number of migrations that were skipped (already completed or not applicable) pub skipped: i32, } @@ -134,7 +138,6 @@ impl MigrationController { succeeded: 0, failed_retryable: 0, failed_terminal: 0, - blocked: 0, skipped: 0, }; @@ -145,28 +148,77 @@ impl MigrationController { // Load the current record for this migration (or create new one if first time) let mut record = self.load_record(&migration_id)?; - // Skip if already succeeded - if matches!(record.status, MigrationStatus::Succeeded) { - info!("Migration {migration_id} already succeeded, skipping"); - summary.skipped += 1; - continue; - } - - // Skip if terminal failure (non-retryable) - if matches!(record.status, MigrationStatus::FailedTerminal) { - info!("Migration {migration_id} failed terminally, skipping"); - summary.skipped += 1; - continue; - } + // Determine if this migration should be attempted based on its current status + let should_attempt = match record.status { + MigrationStatus::Succeeded => { + // Terminal state - migration completed successfully + info!("Migration {migration_id} already succeeded, skipping"); + summary.skipped += 1; + false + } - // Check if we should retry (based on next_attempt_at) - if let Some(next_attempt) = record.next_attempt_at { - let now = Utc::now(); - if now < next_attempt { - info!("Migration {migration_id} scheduled for retry at {next_attempt}, skipping"); + MigrationStatus::FailedTerminal => { + // Terminal state - migration failed permanently + info!("Migration {migration_id} failed terminally, skipping"); summary.skipped += 1; - continue; + false + } + + MigrationStatus::InProgress => { + // Check for staleness (app crash recovery) + // InProgress without timestamp is treated as stale + let is_stale = + record.last_attempted_at.is_none_or(|last_attempted| { + let elapsed = Utc::now() - last_attempted; + elapsed > Duration::minutes(STALE_IN_PROGRESS_MINS) + }); + + if is_stale { + warn!( + "Migration {migration_id} stuck in InProgress, resetting to retryable" + ); + record.status = MigrationStatus::FailedRetryable; + record.last_error_code = Some("STALE_IN_PROGRESS".to_string()); + record.last_error_message = Some( + "Migration was stuck in InProgress state, likely due to app crash" + .to_string(), + ); + // Schedule immediate retry + record.next_attempt_at = Some(Utc::now()); + self.save_record(&migration_id, &record)?; + true // Proceed to retry + } else { + // Fresh InProgress - skip to avoid concurrent execution + info!( + "Migration {migration_id} currently in progress, skipping" + ); + summary.skipped += 1; + false + } + } + + MigrationStatus::FailedRetryable => { + // Check retry timing (exponential backoff) + // No retry time set = attempt immediately + record.next_attempt_at.is_none_or(|next_attempt| { + if Utc::now() < next_attempt { + info!("Migration {migration_id} scheduled for retry at {next_attempt}, skipping"); + summary.skipped += 1; + false + } else { + true // Ready to retry + } + }) + } + + MigrationStatus::NotStarted => { + // First time attempting this migration + true } + }; + + if !should_attempt { + continue; } // Check if migration is applicable and should run, based on processor defined logic. @@ -206,7 +258,6 @@ impl MigrationController { // Save record before execution so we don't lose progress if the app crashes mid-migration self.save_record(&migration_id, &record)?; - // Execute match processor.execute().await { Ok(ProcessorResult::Success) => { info!("Migration {migration_id} succeeded"); @@ -249,13 +300,6 @@ impl MigrationController { record.next_attempt_at = None; // Clear retry time summary.failed_terminal += 1; } - Ok(ProcessorResult::BlockedUserAction { reason }) => { - warn!("Migration {migration_id} blocked: {reason}"); - record.status = MigrationStatus::BlockedUserAction; - record.last_error_message = Some(reason); - record.next_attempt_at = None; // Clear retry time - summary.blocked += 1; - } Err(e) => { error!("Migration {migration_id} threw error: {e:?}"); record.status = MigrationStatus::FailedRetryable; @@ -276,8 +320,8 @@ impl MigrationController { } info!( - "Migration run completed: {} succeeded, {} retryable, {} terminal, {} blocked, {} skipped", - summary.succeeded, summary.failed_retryable, summary.failed_terminal, summary.blocked, summary.skipped + "Migration run completed: {} succeeded, {} retryable, {} terminal, {} skipped", + summary.succeeded, summary.failed_retryable, summary.failed_terminal, summary.skipped ); Ok(summary) @@ -725,4 +769,440 @@ mod tests { // Processor should only execute once assert_eq!(processor.execution_count(), 1); } + + #[tokio::test] + #[serial] + async fn test_stale_in_progress_resets_and_retries() { + let kv_store = Arc::new(InMemoryDeviceKeyValueStore::new()); + let processor = Arc::new(TestProcessor::new("test.migration.v1")); + + // Create a stale InProgress record (simulating app crash) + let mut record = MigrationRecord::new(); + record.status = MigrationStatus::InProgress; + record.attempts = 1; + // Set last_attempted_at to 10 minutes ago (stale) + record.last_attempted_at = Some(Utc::now() - chrono::Duration::minutes(10)); + + let key = format!("{MIGRATION_KEY_PREFIX}test.migration.v1"); + let json = serde_json::to_string(&record).unwrap(); + kv_store.set(key.clone(), json).unwrap(); + + let controller = MigrationController::with_processors( + kv_store.clone(), + vec![processor.clone()], + ); + + // Run migrations - should detect staleness and retry + let result = controller.run_migrations().await; + assert!(result.is_ok()); + + let summary = result.unwrap(); + assert_eq!(summary.total, 1); + assert_eq!(summary.succeeded, 1); // Should succeed after reset + assert_eq!(summary.failed_retryable, 0); + + // Verify the migration was executed + assert_eq!(processor.execution_count(), 1); + + // Verify the final status is Succeeded + let record_json = kv_store.get(key).expect("Record should exist"); + let updated_record: MigrationRecord = + serde_json::from_str(&record_json).expect("Should deserialize"); + assert!(matches!(updated_record.status, MigrationStatus::Succeeded)); + // Attempt counter should have incremented + assert_eq!(updated_record.attempts, 2); + } + + #[tokio::test] + #[serial] + async fn test_fresh_in_progress_not_treated_as_stale() { + let kv_store = Arc::new(InMemoryDeviceKeyValueStore::new()); + let processor = Arc::new(TestProcessor::new("test.migration.v1")); + + // Create a fresh InProgress record (recent) + let mut record = MigrationRecord::new(); + record.status = MigrationStatus::InProgress; + record.attempts = 1; + // Set last_attempted_at to now (not stale) + record.last_attempted_at = Some(Utc::now()); + + let key = format!("{MIGRATION_KEY_PREFIX}test.migration.v1"); + let json = serde_json::to_string(&record).unwrap(); + kv_store.set(key.clone(), json).unwrap(); + + let controller = MigrationController::with_processors( + kv_store.clone(), + vec![processor.clone()], + ); + + // Run migrations - should treat as normal InProgress and retry + let result = controller.run_migrations().await; + assert!(result.is_ok()); + + let summary = result.unwrap(); + assert_eq!(summary.total, 1); + assert_eq!(summary.succeeded, 1); + + // Verify the migration was executed + assert_eq!(processor.execution_count(), 1); + } + + #[tokio::test] + #[serial] + async fn test_succeeded_state_is_terminal_and_skipped() { + let kv_store = Arc::new(InMemoryDeviceKeyValueStore::new()); + let processor = Arc::new(TestProcessor::new("test.migration.v1")); + + // Manually create a Succeeded record + let mut record = MigrationRecord::new(); + record.status = MigrationStatus::Succeeded; + record.completed_at = Some(Utc::now()); + + let key = format!("{MIGRATION_KEY_PREFIX}test.migration.v1"); + let json = serde_json::to_string(&record).unwrap(); + kv_store.set(key.clone(), json).unwrap(); + + let controller = MigrationController::with_processors( + kv_store.clone(), + vec![processor.clone()], + ); + + // Run migrations - should skip + let result = controller.run_migrations().await; + assert!(result.is_ok()); + + let summary = result.unwrap(); + assert_eq!(summary.total, 1); + assert_eq!(summary.skipped, 1); + assert_eq!(summary.succeeded, 0); + + // Verify is_applicable() was NOT called (processor was never executed) + assert_eq!(processor.execution_count(), 0); + + // Verify status is still Succeeded + let record_json = kv_store.get(key).expect("Record should exist"); + let updated_record: MigrationRecord = + serde_json::from_str(&record_json).expect("Should deserialize"); + assert!(matches!(updated_record.status, MigrationStatus::Succeeded)); + } + + #[tokio::test] + #[serial] + async fn test_failed_terminal_state_is_permanent_and_skipped() { + let kv_store = Arc::new(InMemoryDeviceKeyValueStore::new()); + let processor = Arc::new(TestProcessor::new("test.migration.v1")); + + // Manually create a FailedTerminal record + let mut record = MigrationRecord::new(); + record.status = MigrationStatus::FailedTerminal; + record.last_error_code = Some("TERMINAL_ERROR".to_string()); + record.last_error_message = Some("Permanent failure".to_string()); + + let key = format!("{MIGRATION_KEY_PREFIX}test.migration.v1"); + let json = serde_json::to_string(&record).unwrap(); + kv_store.set(key.clone(), json).unwrap(); + + let controller = MigrationController::with_processors( + kv_store.clone(), + vec![processor.clone()], + ); + + // Run migrations multiple times - should always skip + for _ in 0..3 { + let result = controller.run_migrations().await; + assert!(result.is_ok()); + + let summary = result.unwrap(); + assert_eq!(summary.skipped, 1); + assert_eq!(summary.succeeded, 0); + } + + // Verify processor was never executed + assert_eq!(processor.execution_count(), 0); + + // Verify status is still FailedTerminal + let record_json = kv_store.get(key).expect("Record should exist"); + let updated_record: MigrationRecord = + serde_json::from_str(&record_json).expect("Should deserialize"); + assert!(matches!( + updated_record.status, + MigrationStatus::FailedTerminal + )); + } + + #[tokio::test] + #[serial] + async fn test_not_started_state_checks_applicability_and_executes() { + let kv_store = Arc::new(InMemoryDeviceKeyValueStore::new()); + let processor = Arc::new(TestProcessor::new("test.migration.v1")); + + let controller = MigrationController::with_processors( + kv_store.clone(), + vec![processor.clone()], + ); + + // Run migrations - NotStarted should check is_applicable and execute + let result = controller.run_migrations().await; + assert!(result.is_ok()); + + let summary = result.unwrap(); + assert_eq!(summary.total, 1); + assert_eq!(summary.succeeded, 1); + + // Verify processor was executed + assert_eq!(processor.execution_count(), 1); + + // Verify status transitioned to Succeeded + let key = format!("{MIGRATION_KEY_PREFIX}test.migration.v1"); + let record_json = kv_store.get(key).expect("Record should exist"); + let record: MigrationRecord = + serde_json::from_str(&record_json).expect("Should deserialize"); + assert!(matches!(record.status, MigrationStatus::Succeeded)); + assert_eq!(record.attempts, 1); + } + + #[tokio::test] + #[serial] + async fn test_failed_retryable_respects_backoff_timing() { + let kv_store = Arc::new(InMemoryDeviceKeyValueStore::new()); + let processor = Arc::new(TestProcessor::new("test.migration.v1")); + + // Create FailedRetryable record with retry scheduled for future + let mut record = MigrationRecord::new(); + record.status = MigrationStatus::FailedRetryable; + record.attempts = 1; + record.last_error_code = Some("NETWORK_ERROR".to_string()); + record.next_attempt_at = Some(Utc::now() + chrono::Duration::hours(1)); // 1 hour in future + + let key = format!("{MIGRATION_KEY_PREFIX}test.migration.v1"); + let json = serde_json::to_string(&record).unwrap(); + kv_store.set(key.clone(), json).unwrap(); + + let controller = MigrationController::with_processors( + kv_store.clone(), + vec![processor.clone()], + ); + + // Run migrations - should skip due to retry timing + let result = controller.run_migrations().await; + assert!(result.is_ok()); + + let summary = result.unwrap(); + assert_eq!(summary.total, 1); + assert_eq!(summary.skipped, 1); + assert_eq!(summary.succeeded, 0); + + // Verify processor was NOT executed + assert_eq!(processor.execution_count(), 0); + + // Verify status is still FailedRetryable + let record_json = kv_store.get(key).expect("Record should exist"); + let updated_record: MigrationRecord = + serde_json::from_str(&record_json).expect("Should deserialize"); + assert!(matches!( + updated_record.status, + MigrationStatus::FailedRetryable + )); + assert_eq!(updated_record.attempts, 1); // Attempts should not increment + } + + #[tokio::test] + #[serial] + async fn test_failed_retryable_retries_when_backoff_expires() { + let kv_store = Arc::new(InMemoryDeviceKeyValueStore::new()); + let processor = Arc::new(TestProcessor::new("test.migration.v1")); + + // Create FailedRetryable record with retry scheduled for past + let mut record = MigrationRecord::new(); + record.status = MigrationStatus::FailedRetryable; + record.attempts = 1; + record.last_error_code = Some("NETWORK_ERROR".to_string()); + record.next_attempt_at = Some(Utc::now() - chrono::Duration::minutes(5)); // 5 minutes ago + + let key = format!("{MIGRATION_KEY_PREFIX}test.migration.v1"); + let json = serde_json::to_string(&record).unwrap(); + kv_store.set(key.clone(), json).unwrap(); + + let controller = MigrationController::with_processors( + kv_store.clone(), + vec![processor.clone()], + ); + + // Run migrations - should retry and succeed + let result = controller.run_migrations().await; + assert!(result.is_ok()); + + let summary = result.unwrap(); + assert_eq!(summary.total, 1); + assert_eq!(summary.succeeded, 1); + assert_eq!(summary.skipped, 0); + + // Verify processor was executed + assert_eq!(processor.execution_count(), 1); + + // Verify status transitioned to Succeeded + let record_json = kv_store.get(key).expect("Record should exist"); + let updated_record: MigrationRecord = + serde_json::from_str(&record_json).expect("Should deserialize"); + assert!(matches!(updated_record.status, MigrationStatus::Succeeded)); + assert_eq!(updated_record.attempts, 2); // Should increment from 1 to 2 + } + + #[tokio::test] + #[serial] + async fn test_failed_retryable_without_retry_time_executes_immediately() { + let kv_store = Arc::new(InMemoryDeviceKeyValueStore::new()); + let processor = Arc::new(TestProcessor::new("test.migration.v1")); + + // Create FailedRetryable record without next_attempt_at + let mut record = MigrationRecord::new(); + record.status = MigrationStatus::FailedRetryable; + record.attempts = 2; + record.next_attempt_at = None; // No retry time set + + let key = format!("{MIGRATION_KEY_PREFIX}test.migration.v1"); + let json = serde_json::to_string(&record).unwrap(); + kv_store.set(key.clone(), json).unwrap(); + + let controller = MigrationController::with_processors( + kv_store.clone(), + vec![processor.clone()], + ); + + // Run migrations - should execute immediately + let result = controller.run_migrations().await; + assert!(result.is_ok()); + + let summary = result.unwrap(); + assert_eq!(summary.succeeded, 1); + + // Verify processor was executed + assert_eq!(processor.execution_count(), 1); + + // Verify status transitioned to Succeeded + let record_json = kv_store.get(key).expect("Record should exist"); + let updated_record: MigrationRecord = + serde_json::from_str(&record_json).expect("Should deserialize"); + assert!(matches!(updated_record.status, MigrationStatus::Succeeded)); + } + + #[tokio::test] + #[serial] + async fn test_in_progress_without_timestamp_treated_as_stale() { + let kv_store = Arc::new(InMemoryDeviceKeyValueStore::new()); + let processor = Arc::new(TestProcessor::new("test.migration.v1")); + + // Create InProgress record without last_attempted_at timestamp + let mut record = MigrationRecord::new(); + record.status = MigrationStatus::InProgress; + record.attempts = 1; + record.last_attempted_at = None; // No timestamp + + let key = format!("{MIGRATION_KEY_PREFIX}test.migration.v1"); + let json = serde_json::to_string(&record).unwrap(); + kv_store.set(key.clone(), json).unwrap(); + + let controller = MigrationController::with_processors( + kv_store.clone(), + vec![processor.clone()], + ); + + // Run migrations - should treat as stale and retry + let result = controller.run_migrations().await; + assert!(result.is_ok()); + + let summary = result.unwrap(); + assert_eq!(summary.succeeded, 1); + + // Verify processor was executed + assert_eq!(processor.execution_count(), 1); + + // Verify the record was reset and completed + let record_json = kv_store.get(key).expect("Record should exist"); + let updated_record: MigrationRecord = + serde_json::from_str(&record_json).expect("Should deserialize"); + assert!(matches!(updated_record.status, MigrationStatus::Succeeded)); + } + + #[tokio::test] + #[serial] + async fn test_state_based_execution_order() { + let kv_store = Arc::new(InMemoryDeviceKeyValueStore::new()); + + // Create 5 processors with different states + let processor1 = Arc::new(TestProcessor::new("test.succeeded.v1")); + let processor2 = Arc::new(TestProcessor::new("test.terminal.v1")); + let processor3 = Arc::new(TestProcessor::new("test.retryable.v1")); + let processor4 = Arc::new(TestProcessor::new("test.in_progress.v1")); + let processor5 = Arc::new(TestProcessor::new("test.not_started.v1")); + + // Set up initial states + let mut succeeded = MigrationRecord::new(); + succeeded.status = MigrationStatus::Succeeded; + kv_store + .set( + format!("{MIGRATION_KEY_PREFIX}test.succeeded.v1"), + serde_json::to_string(&succeeded).unwrap(), + ) + .unwrap(); + + let mut terminal = MigrationRecord::new(); + terminal.status = MigrationStatus::FailedTerminal; + kv_store + .set( + format!("{MIGRATION_KEY_PREFIX}test.terminal.v1"), + serde_json::to_string(&terminal).unwrap(), + ) + .unwrap(); + + let mut retryable = MigrationRecord::new(); + retryable.status = MigrationStatus::FailedRetryable; + retryable.next_attempt_at = Some(Utc::now() - chrono::Duration::minutes(1)); // Ready to retry + kv_store + .set( + format!("{MIGRATION_KEY_PREFIX}test.retryable.v1"), + serde_json::to_string(&retryable).unwrap(), + ) + .unwrap(); + + let mut in_progress = MigrationRecord::new(); + in_progress.status = MigrationStatus::InProgress; + in_progress.last_attempted_at = Some(Utc::now()); + kv_store + .set( + format!("{MIGRATION_KEY_PREFIX}test.in_progress.v1"), + serde_json::to_string(&in_progress).unwrap(), + ) + .unwrap(); + + // NotStarted doesn't need setup - it's the default + + let controller = MigrationController::with_processors( + kv_store.clone(), + vec![ + processor1.clone(), + processor2.clone(), + processor3.clone(), + processor4.clone(), + processor5.clone(), + ], + ); + + // Run migrations + let result = controller.run_migrations().await; + assert!(result.is_ok()); + + let summary = result.unwrap(); + assert_eq!(summary.total, 5); + // In test mode (STALE_IN_PROGRESS_MINS=0), InProgress is treated as stale and executed + assert_eq!(summary.succeeded, 3); // retryable + in_progress + not_started + assert_eq!(summary.skipped, 2); // succeeded + terminal + + // Verify execution counts + assert_eq!(processor1.execution_count(), 0); // Succeeded - skipped + assert_eq!(processor2.execution_count(), 0); // Terminal - skipped + assert_eq!(processor3.execution_count(), 1); // Retryable - executed + assert_eq!(processor4.execution_count(), 1); // InProgress - treated as stale, executed + assert_eq!(processor5.execution_count(), 1); // NotStarted - executed + } } diff --git a/bedrock/src/migration/processor.rs b/bedrock/src/migration/processor.rs index 7c637fd0..b8b8a9c5 100644 --- a/bedrock/src/migration/processor.rs +++ b/bedrock/src/migration/processor.rs @@ -24,15 +24,24 @@ pub enum ProcessorResult { /// Human-readable error message error_message: String, }, - - /// Migration blocked pending user action - BlockedUserAction { - /// Reason why the migration is blocked - reason: String, - }, } /// Trait that all migration processors must implement +/// +/// # Timeouts and Cancellation Safety +/// +/// Both [`is_applicable`](Self::is_applicable) and [`execute`](Self::execute) are subject to timeouts +/// (20 seconds in production). When a timeout occurs, the future is dropped and the migration +/// is marked as failed (for `execute`) or skipped (for `is_applicable`). +/// +/// **IMPORTANT**: Implementations MUST be cancellation-safe: +/// +/// - **DO NOT** spawn background tasks using `tokio::spawn`, `std::thread::spawn`, or similar +/// that will continue running after the timeout +/// - **DO NOT** use blocking operations or FFI calls without proper cleanup +/// - **ENSURE** all work stops when the future is dropped (cooperative cancellation) +/// - **MAKE** migrations idempotent so partial execution can be safely retried +/// #[uniffi::export(with_foreign)] #[async_trait] pub trait MigrationProcessor: Send + Sync { @@ -46,7 +55,6 @@ pub trait MigrationProcessor: Send + Sync { /// to determine if the migration needs to run. This ensures the system is /// truly idempotent and handles edge cases gracefully. /// - /// /// # Returns /// - `Ok(true)` if the migration should run /// - `Ok(false)` if the migration should be skipped @@ -54,6 +62,5 @@ pub trait MigrationProcessor: Send + Sync { async fn is_applicable(&self) -> Result; /// Execute the migration - /// Called by the controller when the migration is ready to run async fn execute(&self) -> Result; } diff --git a/bedrock/src/migration/state.rs b/bedrock/src/migration/state.rs index 123ceca7..04645cf2 100644 --- a/bedrock/src/migration/state.rs +++ b/bedrock/src/migration/state.rs @@ -14,8 +14,6 @@ pub enum MigrationStatus { FailedRetryable, /// Migration failed with terminal error (won't retry) FailedTerminal, - /// Migration blocked pending user action - BlockedUserAction, } /// Record of a single migration's execution state