From 2966490ede07c706db8e49bb6923c865203263e2 Mon Sep 17 00:00:00 2001 From: bittoby <218712309+bittoby@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:27:03 +0000 Subject: [PATCH 1/3] fix: prevent zero-valued entries in Alpha storage maps and add multi-block on_idle migration to purge existing ones --- pallets/subtensor/src/coinbase/root.rs | 6 +- .../subtensor/src/coinbase/run_coinbase.rs | 13 +- pallets/subtensor/src/lib.rs | 5 + pallets/subtensor/src/macros/hooks.rs | 9 +- .../migrations/migrate_remove_zero_alpha.rs | 208 ++++++++++++++++++ pallets/subtensor/src/migrations/mod.rs | 1 + pallets/subtensor/src/staking/claim_root.rs | 16 +- pallets/subtensor/src/staking/set_children.rs | 8 +- pallets/subtensor/src/swap/swap_coldkey.rs | 8 +- pallets/subtensor/src/swap/swap_hotkey.rs | 81 ++++--- pallets/subtensor/src/tests/migration.rs | 135 ++++++++++++ pallets/subtensor/src/tests/swap_coldkey.rs | 35 +++ pallets/subtensor/src/tests/swap_hotkey.rs | 63 ++++++ 13 files changed, 539 insertions(+), 49 deletions(-) create mode 100644 pallets/subtensor/src/migrations/migrate_remove_zero_alpha.rs diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index db5b4fe2cc..e4f9597183 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -587,7 +587,11 @@ impl Pallet { LastRateLimitedBlock::::get(rate_limit_key) } pub fn set_rate_limited_last_block(rate_limit_key: &RateLimitKey, block: u64) { - LastRateLimitedBlock::::insert(rate_limit_key, block); + if block == 0 { + LastRateLimitedBlock::::remove(rate_limit_key); + } else { + LastRateLimitedBlock::::insert(rate_limit_key, block); + } } pub fn remove_rate_limited_last_block(rate_limit_key: &RateLimitKey) { LastRateLimitedBlock::::remove(rate_limit_key); diff --git a/pallets/subtensor/src/coinbase/run_coinbase.rs b/pallets/subtensor/src/coinbase/run_coinbase.rs index 460a754d45..ea5a30c000 100644 --- a/pallets/subtensor/src/coinbase/run_coinbase.rs +++ b/pallets/subtensor/src/coinbase/run_coinbase.rs @@ -598,13 +598,18 @@ impl Pallet { log::debug!("hotkey: {hotkey:?} alpha_divs: {alpha_divs:?}"); Self::increase_stake_for_hotkey_on_subnet(&hotkey, netuid, tou64!(alpha_divs).into()); // Record dividends for this hotkey. - AlphaDividendsPerSubnet::::mutate(netuid, &hotkey, |divs| { - *divs = divs.saturating_add(tou64!(alpha_divs).into()); - }); + let alpha_divs_u64: u64 = tou64!(alpha_divs); + if alpha_divs_u64 != 0 { + AlphaDividendsPerSubnet::::mutate(netuid, &hotkey, |divs| { + *divs = divs.saturating_add(alpha_divs_u64.into()); + }); + } // Record total hotkey alpha based on which this value of AlphaDividendsPerSubnet // was calculated let total_hotkey_alpha = TotalHotkeyAlpha::::get(&hotkey, netuid); - TotalHotkeyAlphaLastEpoch::::insert(hotkey, netuid, total_hotkey_alpha); + if !total_hotkey_alpha.is_zero() { + TotalHotkeyAlphaLastEpoch::::insert(hotkey, netuid, total_hotkey_alpha); + } } // Distribute root alpha divs. diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 54ccadc1a1..0768b54948 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -2441,6 +2441,11 @@ pub mod pallet { #[pallet::storage] pub type HasMigrationRun = StorageMap<_, Identity, Vec, bool, ValueQuery>; + /// --- Tracks the current phase of the zero-alpha multi-block cleanup. + /// 0 = inactive/complete, 1-4 = active phases (Alpha, TotalHotkeyShares, etc.) + #[pallet::storage] + pub type ZeroAlphaCleanupPhase = StorageValue<_, u8, ValueQuery>; + /// Default value for pending childkey cooldown (settable by root). /// Uses the same value as DefaultPendingCooldown for consistency. #[pallet::type_value] diff --git a/pallets/subtensor/src/macros/hooks.rs b/pallets/subtensor/src/macros/hooks.rs index 899e8d32f2..a0cfdc9c26 100644 --- a/pallets/subtensor/src/macros/hooks.rs +++ b/pallets/subtensor/src/macros/hooks.rs @@ -38,6 +38,11 @@ mod hooks { } } + // ---- Called when the block has leftover weight. Used for multi-block migrations. + fn on_idle(_block_number: BlockNumberFor, remaining_weight: Weight) -> Weight { + migrations::migrate_remove_zero_alpha::on_idle_remove_zero_alpha::(remaining_weight) + } + // ---- Called on the finalization of this pallet. The code weight must be taken into account prior to the execution of this macro. // // # Args: @@ -166,7 +171,9 @@ mod hooks { // Fix staking hot keys .saturating_add(migrations::migrate_fix_staking_hot_keys::migrate_fix_staking_hot_keys::()) // Migrate coldkey swap scheduled to announcements - .saturating_add(migrations::migrate_coldkey_swap_scheduled_to_announcements::migrate_coldkey_swap_scheduled_to_announcements::()); + .saturating_add(migrations::migrate_coldkey_swap_scheduled_to_announcements::migrate_coldkey_swap_scheduled_to_announcements::()) + // Remove zero-valued entries from Alpha and related storage maps + .saturating_add(migrations::migrate_remove_zero_alpha::migrate_remove_zero_alpha::()); weight } diff --git a/pallets/subtensor/src/migrations/migrate_remove_zero_alpha.rs b/pallets/subtensor/src/migrations/migrate_remove_zero_alpha.rs new file mode 100644 index 0000000000..f281b112e3 --- /dev/null +++ b/pallets/subtensor/src/migrations/migrate_remove_zero_alpha.rs @@ -0,0 +1,208 @@ +use super::*; +use frame_support::{traits::Get, weights::Weight}; +use log; +use scale_info::prelude::string::String; + +/// The migration name used for the `HasMigrationRun` guard. +const MIGRATION_NAME: &[u8] = b"migrate_remove_zero_alpha_v2"; + +/// Called from `on_runtime_upgrade`. Schedules the cleanup by setting phase = 1 +/// if the migration hasn't run yet. This is O(1) — no iteration. +pub fn migrate_remove_zero_alpha() -> Weight { + let migration_name = MIGRATION_NAME.to_vec(); + let mut weight = T::DbWeight::get().reads(1); + + if HasMigrationRun::::get(&migration_name) { + log::info!( + "Migration '{}' already completed. Skipping.", + String::from_utf8_lossy(&migration_name) + ); + return weight; + } + + // Schedule the cleanup to run in on_idle by setting phase to 1 + ZeroAlphaCleanupPhase::::put(1u8); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + + log::info!( + "Migration '{}' scheduled. Will clean up zero entries via on_idle.", + String::from_utf8_lossy(&migration_name) + ); + + weight +} + +/// Called from `on_idle` each block. Processes one storage map per block, +/// removing all zero-valued entries. Advances to the next phase when done. +/// +/// Phases: +/// 0 = inactive/complete +/// 1 = cleaning Alpha +/// 2 = cleaning TotalHotkeyShares +/// 3 = cleaning TotalHotkeyAlphaLastEpoch +/// 4 = cleaning AlphaDividendsPerSubnet +pub fn on_idle_remove_zero_alpha(remaining_weight: Weight) -> Weight { + let phase = ZeroAlphaCleanupPhase::::get(); + + // Phase 0 means not active or already completed + if phase == 0 { + return Weight::zero(); + } + + // Minimum weight needed: 1 read (phase) + at least some work + let min_weight = T::DbWeight::get().reads_writes(2, 1); + if remaining_weight.ref_time() < min_weight.ref_time() { + return Weight::zero(); + } + + let mut weight = T::DbWeight::get().reads(1); // reading phase + + match phase { + 1 => { + let (consumed, removed) = clean_alpha::(); + weight = weight.saturating_add(consumed); + log::info!("Zero-alpha cleanup: Alpha complete. Removed {removed} zero entries."); + ZeroAlphaCleanupPhase::::put(2u8); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + } + 2 => { + let (consumed, removed) = clean_total_hotkey_shares::(); + weight = weight.saturating_add(consumed); + log::info!( + "Zero-alpha cleanup: TotalHotkeyShares complete. Removed {removed} zero entries." + ); + ZeroAlphaCleanupPhase::::put(3u8); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + } + 3 => { + let (consumed, removed) = clean_total_hotkey_alpha_last_epoch::(); + weight = weight.saturating_add(consumed); + log::info!( + "Zero-alpha cleanup: TotalHotkeyAlphaLastEpoch complete. Removed {removed} zero entries." + ); + ZeroAlphaCleanupPhase::::put(4u8); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + } + 4 => { + let (consumed, removed) = clean_alpha_dividends_per_subnet::(); + weight = weight.saturating_add(consumed); + log::info!( + "Zero-alpha cleanup: AlphaDividendsPerSubnet complete. Removed {removed} zero entries." + ); + + // All phases complete — mark migration as done + HasMigrationRun::::insert(MIGRATION_NAME.to_vec(), true); + ZeroAlphaCleanupPhase::::put(0u8); + weight = weight.saturating_add(T::DbWeight::get().writes(2)); + log::info!("Zero-alpha cleanup: All phases complete. Migration marked as done."); + } + _ => { + // Unknown phase, reset + ZeroAlphaCleanupPhase::::put(0u8); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + } + } + + weight +} + +/// Remove all zero-valued entries from Alpha. +/// Returns (weight_consumed, entries_removed). +fn clean_alpha() -> (Weight, u64) { + let mut weight = Weight::zero(); + let mut removed = 0u64; + + let to_remove: Vec<_> = Alpha::::iter() + .filter_map(|((hotkey, coldkey, netuid), value)| { + weight = weight.saturating_add(T::DbWeight::get().reads(1)); + if value == 0 { + Some((hotkey, coldkey, netuid)) + } else { + None + } + }) + .collect(); + + for (hotkey, coldkey, netuid) in &to_remove { + Alpha::::remove((hotkey, coldkey, *netuid)); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + removed = removed.saturating_add(1); + } + + (weight, removed) +} + +/// Remove all zero-valued entries from TotalHotkeyShares. +fn clean_total_hotkey_shares() -> (Weight, u64) { + let mut weight = Weight::zero(); + let mut removed = 0u64; + + let to_remove: Vec<_> = TotalHotkeyShares::::iter() + .filter_map(|(hotkey, netuid, value)| { + weight = weight.saturating_add(T::DbWeight::get().reads(1)); + if value == 0 { + Some((hotkey, netuid)) + } else { + None + } + }) + .collect(); + + for (hotkey, netuid) in &to_remove { + TotalHotkeyShares::::remove(hotkey, *netuid); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + removed = removed.saturating_add(1); + } + + (weight, removed) +} + +/// Remove all zero-valued entries from TotalHotkeyAlphaLastEpoch. +fn clean_total_hotkey_alpha_last_epoch() -> (Weight, u64) { + let mut weight = Weight::zero(); + let mut removed = 0u64; + + let to_remove: Vec<_> = TotalHotkeyAlphaLastEpoch::::iter() + .filter_map(|(hotkey, netuid, value)| { + weight = weight.saturating_add(T::DbWeight::get().reads(1)); + if value.is_zero() { + Some((hotkey, netuid)) + } else { + None + } + }) + .collect(); + + for (hotkey, netuid) in &to_remove { + TotalHotkeyAlphaLastEpoch::::remove(hotkey, *netuid); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + removed = removed.saturating_add(1); + } + + (weight, removed) +} + +/// Remove all zero-valued entries from AlphaDividendsPerSubnet. +fn clean_alpha_dividends_per_subnet() -> (Weight, u64) { + let mut weight = Weight::zero(); + let mut removed = 0u64; + + let to_remove: Vec<_> = AlphaDividendsPerSubnet::::iter() + .filter_map(|(netuid, hotkey, value)| { + weight = weight.saturating_add(T::DbWeight::get().reads(1)); + if value.is_zero() { + Some((netuid, hotkey)) + } else { + None + } + }) + .collect(); + + for (netuid, hotkey) in &to_remove { + AlphaDividendsPerSubnet::::remove(*netuid, hotkey); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + removed = removed.saturating_add(1); + } + + (weight, removed) +} diff --git a/pallets/subtensor/src/migrations/mod.rs b/pallets/subtensor/src/migrations/mod.rs index 23a2899b94..93c664d3df 100644 --- a/pallets/subtensor/src/migrations/mod.rs +++ b/pallets/subtensor/src/migrations/mod.rs @@ -41,6 +41,7 @@ pub mod migrate_remove_tao_dividends; pub mod migrate_remove_total_hotkey_coldkey_stakes_this_interval; pub mod migrate_remove_unknown_neuron_axon_cert_prom; pub mod migrate_remove_unused_maps_and_values; +pub mod migrate_remove_zero_alpha; pub mod migrate_remove_zero_total_hotkey_alpha; pub mod migrate_reset_bonds_moving_average; pub mod migrate_reset_max_burn; diff --git a/pallets/subtensor/src/staking/claim_root.rs b/pallets/subtensor/src/staking/claim_root.rs index 58babb79a6..228516ec9f 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -263,8 +263,12 @@ impl Pallet { .saturating_to_num(), ); - // Set the new root claimed value. - RootClaimed::::insert((netuid, hotkey, coldkey), new_root_claimed); + // Set the new root claimed value, or remove if zero to avoid storage bloat. + if new_root_claimed != 0 { + RootClaimed::::insert((netuid, hotkey, coldkey), new_root_claimed); + } else { + RootClaimed::::remove((netuid, hotkey, coldkey)); + } } } @@ -290,8 +294,12 @@ impl Pallet { .saturating_to_num(), ); - // Set the new root_claimed value. - RootClaimed::::insert((netuid, hotkey, coldkey), new_root_claimed); + // Set the new root_claimed value, removing if zero to avoid storage bloat. + if new_root_claimed != 0 { + RootClaimed::::insert((netuid, hotkey, coldkey), new_root_claimed); + } else { + RootClaimed::::remove((netuid, hotkey, coldkey)); + } } } diff --git a/pallets/subtensor/src/staking/set_children.rs b/pallets/subtensor/src/staking/set_children.rs index 8082f22c32..4560d23e1b 100644 --- a/pallets/subtensor/src/staking/set_children.rs +++ b/pallets/subtensor/src/staking/set_children.rs @@ -412,15 +412,11 @@ impl Pallet { for (parent, _) in relations.parents().iter() { let mut ck = ChildKeys::::get(parent.clone(), netuid); PCRelations::::remove_edge(&mut ck, old_hotkey); - ChildKeys::::insert(parent.clone(), netuid, ck); + Self::set_childkeys(parent.clone(), netuid, ck); weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 1)); } // 2c) Clear direct maps of old_hotkey - ChildKeys::::insert( - old_hotkey.clone(), - netuid, - Vec::<(u64, T::AccountId)>::new(), - ); + ChildKeys::::remove(old_hotkey.clone(), netuid); Self::set_parentkeys( old_hotkey.clone(), netuid, diff --git a/pallets/subtensor/src/swap/swap_coldkey.rs b/pallets/subtensor/src/swap/swap_coldkey.rs index 27fef995b2..94b4989a76 100644 --- a/pallets/subtensor/src/swap/swap_coldkey.rs +++ b/pallets/subtensor/src/swap/swap_coldkey.rs @@ -160,7 +160,9 @@ impl Pallet { } StakingHotkeys::::remove(old_coldkey); - StakingHotkeys::::insert(new_coldkey, new_staking_hotkeys); + if !new_staking_hotkeys.is_empty() { + StakingHotkeys::::insert(new_coldkey, new_staking_hotkeys); + } } /// Transfer the ownership of the hotkeys owned by the old coldkey to the new coldkey. @@ -178,6 +180,8 @@ impl Pallet { } } OwnedHotkeys::::remove(old_coldkey); - OwnedHotkeys::::insert(new_coldkey, new_owned_hotkeys); + if !new_owned_hotkeys.is_empty() { + OwnedHotkeys::::insert(new_coldkey, new_owned_hotkeys); + } } } diff --git a/pallets/subtensor/src/swap/swap_hotkey.rs b/pallets/subtensor/src/swap/swap_hotkey.rs index 1138ed1cde..ce85e3c282 100644 --- a/pallets/subtensor/src/swap/swap_hotkey.rs +++ b/pallets/subtensor/src/swap/swap_hotkey.rs @@ -364,11 +364,13 @@ impl Pallet { // 1. Swap total hotkey alpha for all subnets it exists on. // TotalHotkeyAlpha( hotkey, netuid ) -> alpha -- the total alpha that the hotkey has on a specific subnet. let alpha = TotalHotkeyAlpha::::take(old_hotkey, netuid); - - TotalHotkeyAlpha::::mutate(new_hotkey, netuid, |value| { - *value = value.saturating_add(alpha) - }); weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 1)); + if !alpha.is_zero() { + TotalHotkeyAlpha::::mutate(new_hotkey, netuid, |value| { + *value = value.saturating_add(alpha) + }); + weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 1)); + } // 2. Swap total hotkey shares on all subnets it exists on. // TotalHotkeyShares( hotkey, netuid ) -> share pool denominator for this hotkey on this subnet. @@ -387,8 +389,11 @@ impl Pallet { let total_old_plus_new_shares = total_new_shares.add(&total_old_shares).unwrap_or_default(); - TotalHotkeySharesV2::::insert(new_hotkey, netuid, total_old_plus_new_shares); - weight.saturating_accrue(T::DbWeight::get().writes(3)); + weight.saturating_accrue(T::DbWeight::get().writes(2)); + if !total_old_plus_new_shares.is_zero() { + TotalHotkeySharesV2::::insert(new_hotkey, netuid, total_old_plus_new_shares); + weight.saturating_accrue(T::DbWeight::get().writes(1)); + } } // 3. Swap all subnet specific info. @@ -397,8 +402,11 @@ impl Pallet { // IsNetworkMember( hotkey, netuid ) -> bool -- is the hotkey a subnet member. let is_network_member: bool = IsNetworkMember::::get(old_hotkey, netuid); IsNetworkMember::::remove(old_hotkey, netuid); - IsNetworkMember::::insert(new_hotkey, netuid, is_network_member); - weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 2)); + weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 1)); + if is_network_member { + IsNetworkMember::::insert(new_hotkey, netuid, is_network_member); + weight.saturating_accrue(T::DbWeight::get().writes(1)); + } // 3.2 Swap Uids + Keys. // Keys( netuid, hotkey ) -> uid -- the uid the hotkey has in the network if it is a member. @@ -523,23 +531,28 @@ impl Pallet { // 8.1 Swap TotalHotkeyAlphaLastEpoch let old_alpha = TotalHotkeyAlphaLastEpoch::::take(old_hotkey, netuid); let new_total_hotkey_alpha = TotalHotkeyAlphaLastEpoch::::get(new_hotkey, netuid); - TotalHotkeyAlphaLastEpoch::::insert( - new_hotkey, - netuid, - old_alpha.saturating_add(new_total_hotkey_alpha), - ); - weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 2)); + let combined_alpha_last_epoch = old_alpha.saturating_add(new_total_hotkey_alpha); + weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 1)); + if !combined_alpha_last_epoch.is_zero() { + TotalHotkeyAlphaLastEpoch::::insert( + new_hotkey, + netuid, + combined_alpha_last_epoch, + ); + weight.saturating_accrue(T::DbWeight::get().writes(1)); + } // 8.2 Swap AlphaDividendsPerSubnet let old_hotkey_alpha_dividends = AlphaDividendsPerSubnet::::get(netuid, old_hotkey); let new_hotkey_alpha_dividends = AlphaDividendsPerSubnet::::get(netuid, new_hotkey); AlphaDividendsPerSubnet::::remove(netuid, old_hotkey); - AlphaDividendsPerSubnet::::insert( - netuid, - new_hotkey, - old_hotkey_alpha_dividends.saturating_add(new_hotkey_alpha_dividends), - ); - weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 2)); + let combined_dividends = + old_hotkey_alpha_dividends.saturating_add(new_hotkey_alpha_dividends); + weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 1)); + if !combined_dividends.is_zero() { + AlphaDividendsPerSubnet::::insert(netuid, new_hotkey, combined_dividends); + weight.saturating_accrue(T::DbWeight::get().writes(1)); + } // 8.3 Swap TaoDividendsPerSubnet // Tao dividends were removed @@ -574,12 +587,16 @@ impl Pallet { let new_alpha = Alpha::::take((new_hotkey, &coldkey, netuid)); Alpha::::remove((old_hotkey, &coldkey, netuid)); - // Insert into AlphaV2 because Alpha is deprecated - AlphaV2::::insert( - (new_hotkey, &coldkey, netuid), - SafeFloat::from(alpha.saturating_add(new_alpha)), - ); - weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 2)); + // Only insert combined alpha if non-zero to avoid storage bloat. + let combined_alpha = alpha.saturating_add(new_alpha); + weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 1)); + if combined_alpha != 0 { + AlphaV2::::insert( + (new_hotkey, &coldkey, netuid), + SafeFloat::from(combined_alpha), + ); + weight.saturating_accrue(T::DbWeight::get().writes(1)); + } // Swap StakingHotkeys. // StakingHotkeys( coldkey ) --> Vec -- the hotkeys that the coldkey stakes. @@ -602,11 +619,13 @@ impl Pallet { let new_alpha_v2 = AlphaV2::::take((new_hotkey, &coldkey, netuid)); AlphaV2::::remove((old_hotkey, &coldkey, netuid)); - AlphaV2::::insert( - (new_hotkey, &coldkey, netuid), - alpha.add(&new_alpha_v2).unwrap_or_default(), - ); - weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 2)); + // Only insert combined alpha if non-zero to avoid storage bloat. + let combined_alpha_v2 = alpha.add(&new_alpha_v2).unwrap_or_default(); + weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 1)); + if !combined_alpha_v2.is_zero() { + AlphaV2::::insert((new_hotkey, &coldkey, netuid), combined_alpha_v2); + weight.saturating_accrue(T::DbWeight::get().writes(1)); + } // Swap StakingHotkeys. // StakingHotkeys( coldkey ) --> Vec -- the hotkeys that the coldkey stakes. diff --git a/pallets/subtensor/src/tests/migration.rs b/pallets/subtensor/src/tests/migration.rs index 7f19c96b3d..f641cee5ae 100644 --- a/pallets/subtensor/src/tests/migration.rs +++ b/pallets/subtensor/src/tests/migration.rs @@ -3122,3 +3122,138 @@ fn test_migrate_coldkey_swap_scheduled_to_announcements() { ); }); } + +#[test] +fn test_migrate_remove_zero_alpha_multi_block() { + new_test_ext(1).execute_with(|| { + const MIGRATION_NAME: &[u8] = b"migrate_remove_zero_alpha_v2"; + let netuid = NetUid::from(1u16); + + let hotkey_zero = U256::from(100u64); + let hotkey_nonzero = U256::from(101u64); + let coldkey_zero = U256::from(200u64); + let coldkey_nonzero = U256::from(201u64); + + let zero = U64F64::from_num(0); + let nonzero = U64F64::from_num(5000); + + // --- Setup: insert zero and non-zero entries across all four maps --- + + // Alpha (StorageNMap) + Alpha::::insert((&hotkey_zero, &coldkey_zero, netuid), zero); + Alpha::::insert((&hotkey_nonzero, &coldkey_nonzero, netuid), nonzero); + + // TotalHotkeyShares + TotalHotkeyShares::::insert(hotkey_zero, netuid, zero); + TotalHotkeyShares::::insert(hotkey_nonzero, netuid, nonzero); + + // TotalHotkeyAlphaLastEpoch + TotalHotkeyAlphaLastEpoch::::insert(hotkey_zero, netuid, AlphaBalance::ZERO); + TotalHotkeyAlphaLastEpoch::::insert(hotkey_nonzero, netuid, AlphaBalance::from(5000)); + + // AlphaDividendsPerSubnet + AlphaDividendsPerSubnet::::insert(netuid, hotkey_zero, AlphaBalance::ZERO); + AlphaDividendsPerSubnet::::insert(netuid, hotkey_nonzero, AlphaBalance::from(5000)); + + // Verify cleanup phase is inactive + assert_eq!(ZeroAlphaCleanupPhase::::get(), 0u8); + + // Step 1: on_runtime_upgrade schedules the cleanup (sets phase to 1) + let weight = + crate::migrations::migrate_remove_zero_alpha::migrate_remove_zero_alpha::(); + assert!(!weight.is_zero(), "Scheduling weight should be non-zero."); + assert_eq!( + ZeroAlphaCleanupPhase::::get(), + 1u8, + "Phase should be 1 after scheduling." + ); + assert!( + !HasMigrationRun::::get(MIGRATION_NAME.to_vec()), + "Migration should NOT be marked as done yet." + ); + + // Step 2: Simulate on_idle calls to process all 4 phases + let large_weight = Weight::from_parts(u64::MAX, u64::MAX); + + // Phase 1: Alpha cleanup + crate::migrations::migrate_remove_zero_alpha::on_idle_remove_zero_alpha::( + large_weight, + ); + assert_eq!(ZeroAlphaCleanupPhase::::get(), 2u8); + + // Phase 2: TotalHotkeyShares cleanup + crate::migrations::migrate_remove_zero_alpha::on_idle_remove_zero_alpha::( + large_weight, + ); + assert_eq!(ZeroAlphaCleanupPhase::::get(), 3u8); + + // Phase 3: TotalHotkeyAlphaLastEpoch cleanup + crate::migrations::migrate_remove_zero_alpha::on_idle_remove_zero_alpha::( + large_weight, + ); + assert_eq!(ZeroAlphaCleanupPhase::::get(), 4u8); + + // Phase 4: AlphaDividendsPerSubnet cleanup — completes and marks migration done + crate::migrations::migrate_remove_zero_alpha::on_idle_remove_zero_alpha::( + large_weight, + ); + assert_eq!(ZeroAlphaCleanupPhase::::get(), 0u8); + assert!( + HasMigrationRun::::get(MIGRATION_NAME.to_vec()), + "Migration should be marked as done." + ); + + // Verify zero entries were removed + assert!( + !Alpha::::contains_key((&hotkey_zero, &coldkey_zero, netuid)), + "Zero Alpha entry should have been removed." + ); + assert!( + !TotalHotkeyShares::::contains_key(hotkey_zero, netuid), + "Zero TotalHotkeyShares entry should have been removed." + ); + assert!( + !TotalHotkeyAlphaLastEpoch::::contains_key(hotkey_zero, netuid), + "Zero TotalHotkeyAlphaLastEpoch entry should have been removed." + ); + assert!( + !AlphaDividendsPerSubnet::::contains_key(netuid, hotkey_zero), + "Zero AlphaDividendsPerSubnet entry should have been removed." + ); + + // Verify non-zero entries were preserved + assert_eq!( + Alpha::::get((&hotkey_nonzero, &coldkey_nonzero, netuid)), + nonzero + ); + assert_eq!( + TotalHotkeyShares::::get(hotkey_nonzero, netuid), + nonzero + ); + assert_eq!( + TotalHotkeyAlphaLastEpoch::::get(hotkey_nonzero, netuid), + AlphaBalance::from(5000) + ); + assert_eq!( + AlphaDividendsPerSubnet::::get(netuid, hotkey_nonzero), + AlphaBalance::from(5000) + ); + + // Verify idempotency: on_idle should be a no-op when phase is 0 + let w_noop = + crate::migrations::migrate_remove_zero_alpha::on_idle_remove_zero_alpha::( + large_weight, + ); + assert!(w_noop.is_zero(), "on_idle should be a no-op when phase is 0."); + + // Verify idempotency: on_runtime_upgrade again should skip + let weight2 = + crate::migrations::migrate_remove_zero_alpha::migrate_remove_zero_alpha::(); + assert_eq!( + weight2, + Weight::from_parts(0, 0) + .saturating_add(::DbWeight::get().reads(1)), + "Second run should only cost a single read (HasMigrationRun check)." + ); + }); +} diff --git a/pallets/subtensor/src/tests/swap_coldkey.rs b/pallets/subtensor/src/tests/swap_coldkey.rs index 512f83dd1d..21437533ad 100644 --- a/pallets/subtensor/src/tests/swap_coldkey.rs +++ b/pallets/subtensor/src/tests/swap_coldkey.rs @@ -1558,6 +1558,41 @@ fn test_schedule_swap_coldkey_deprecated() { }); } +// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --test swap_coldkey -- test_coldkey_swap_does_not_insert_zero_alpha --exact --nocapture +#[test] +fn test_coldkey_swap_does_not_insert_zero_alpha() { + new_test_ext(1).execute_with(|| { + use substrate_fixed::types::U64F64; + + let old_coldkey = U256::from(1); + let new_coldkey = U256::from(2); + let hotkey = U256::from(3); + let netuid = NetUid::from(1); + + // Add network and register hotkey + add_network(netuid, 1, 0); + register_ok_neuron(netuid, hotkey, old_coldkey, 1001000); + + // Insert zero Alpha entry for the old coldkey + Alpha::::insert((&hotkey, &old_coldkey, netuid), U64F64::from_num(0)); + StakingHotkeys::::insert(old_coldkey, vec![hotkey]); + + // Perform the swap + assert_ok!(SubtensorModule::do_swap_coldkey(&old_coldkey, &new_coldkey)); + + // Verify no zero entry was inserted for the new coldkey + assert!( + !Alpha::::contains_key((&hotkey, &new_coldkey, netuid)), + "Alpha should not contain a zero entry for new_coldkey" + ); + // Verify old entry was removed + assert!( + !Alpha::::contains_key((&hotkey, &old_coldkey, netuid)), + "Alpha should not contain entry for old_coldkey" + ); + }); +} + #[macro_export] macro_rules! comprehensive_setup { ( diff --git a/pallets/subtensor/src/tests/swap_hotkey.rs b/pallets/subtensor/src/tests/swap_hotkey.rs index d0a1de3526..225311bf9f 100644 --- a/pallets/subtensor/src/tests/swap_hotkey.rs +++ b/pallets/subtensor/src/tests/swap_hotkey.rs @@ -1635,3 +1635,66 @@ fn test_swap_auto_stake_destination_coldkeys() { ); }); } + +// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --test swap_hotkey -- test_hotkey_swap_does_not_insert_zero_alpha --exact --nocapture +#[test] +fn test_hotkey_swap_does_not_insert_zero_alpha() { + new_test_ext(1).execute_with(|| { + let old_hotkey = U256::from(1); + let new_hotkey = U256::from(2); + let coldkey = U256::from(3); + let subnet_owner_coldkey = U256::from(1001); + let subnet_owner_hotkey = U256::from(1002); + let netuid = add_dynamic_network(&subnet_owner_hotkey, &subnet_owner_coldkey); + let mut weight = Weight::zero(); + + // Insert zero values for all alpha-related maps + TotalHotkeyAlpha::::insert(old_hotkey, netuid, AlphaBalance::ZERO); + TotalHotkeyAlphaLastEpoch::::insert(old_hotkey, netuid, AlphaBalance::ZERO); + TotalHotkeyShares::::insert(old_hotkey, netuid, U64F64::from_num(0)); + Alpha::::insert((old_hotkey, coldkey, netuid), U64F64::from_num(0)); + AlphaDividendsPerSubnet::::insert(netuid, old_hotkey, AlphaBalance::ZERO); + + // Perform the swap + SubtensorModule::perform_hotkey_swap_on_all_subnets( + &old_hotkey, + &new_hotkey, + &coldkey, + &mut weight, + false, + ); + + // Verify no zero entries were inserted for the new hotkey + assert!( + !TotalHotkeyAlpha::::contains_key(new_hotkey, netuid), + "TotalHotkeyAlpha should not contain a zero entry for new_hotkey" + ); + assert!( + !TotalHotkeyAlphaLastEpoch::::contains_key(new_hotkey, netuid), + "TotalHotkeyAlphaLastEpoch should not contain a zero entry for new_hotkey" + ); + assert!( + !TotalHotkeyShares::::contains_key(new_hotkey, netuid), + "TotalHotkeyShares should not contain a zero entry for new_hotkey" + ); + assert!( + !Alpha::::contains_key((new_hotkey, coldkey, netuid)), + "Alpha should not contain a zero entry for new_hotkey" + ); + assert!( + !AlphaDividendsPerSubnet::::contains_key(netuid, new_hotkey), + "AlphaDividendsPerSubnet should not contain a zero entry for new_hotkey" + ); + + // Verify old entries were removed + assert!(!TotalHotkeyAlpha::::contains_key(old_hotkey, netuid)); + assert!(!TotalHotkeyAlphaLastEpoch::::contains_key( + old_hotkey, netuid + )); + assert!(!TotalHotkeyShares::::contains_key(old_hotkey, netuid)); + assert!(!Alpha::::contains_key((old_hotkey, coldkey, netuid))); + assert!(!AlphaDividendsPerSubnet::::contains_key( + netuid, old_hotkey + )); + }); +} From 110408fa456eb68fca514915bec2a8dad108a41d Mon Sep 17 00:00:00 2001 From: bittoby <218712309+bittoby@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:16:29 +0000 Subject: [PATCH 2/3] fix: Use weight-based dynamic batching in migration to prevent block timeouts --- .../migrations/migrate_remove_zero_alpha.rs | 204 +++++++++--------- 1 file changed, 108 insertions(+), 96 deletions(-) diff --git a/pallets/subtensor/src/migrations/migrate_remove_zero_alpha.rs b/pallets/subtensor/src/migrations/migrate_remove_zero_alpha.rs index f281b112e3..29710e5d58 100644 --- a/pallets/subtensor/src/migrations/migrate_remove_zero_alpha.rs +++ b/pallets/subtensor/src/migrations/migrate_remove_zero_alpha.rs @@ -32,8 +32,9 @@ pub fn migrate_remove_zero_alpha() -> Weight { weight } -/// Called from `on_idle` each block. Processes one storage map per block, -/// removing all zero-valued entries. Advances to the next phase when done. +/// Called from `on_idle` each block. Uses `remaining_weight` to dynamically +/// bound how many entries to process. Stays on the same phase until all entries +/// in that map are cleaned, then advances to the next phase. /// /// Phases: /// 0 = inactive/complete @@ -49,7 +50,7 @@ pub fn on_idle_remove_zero_alpha(remaining_weight: Weight) -> Weight return Weight::zero(); } - // Minimum weight needed: 1 read (phase) + at least some work + // Minimum weight needed: 1 read (phase) + at least one iteration (read + write) let min_weight = T::DbWeight::get().reads_writes(2, 1); if remaining_weight.ref_time() < min_weight.ref_time() { return Weight::zero(); @@ -57,44 +58,61 @@ pub fn on_idle_remove_zero_alpha(remaining_weight: Weight) -> Weight let mut weight = T::DbWeight::get().reads(1); // reading phase + // Budget for batch work = remaining_weight minus overhead (phase read + phase write) + let overhead = T::DbWeight::get().reads_writes(1, 1); + let budget = remaining_weight.saturating_sub(overhead); + match phase { 1 => { - let (consumed, removed) = clean_alpha::(); + let (consumed, removed, done) = clean_alpha_batch::(budget); weight = weight.saturating_add(consumed); - log::info!("Zero-alpha cleanup: Alpha complete. Removed {removed} zero entries."); - ZeroAlphaCleanupPhase::::put(2u8); - weight = weight.saturating_add(T::DbWeight::get().writes(1)); + log::info!( + "Zero-alpha cleanup phase 1 (Alpha): removed {removed} zero entries this batch. Done: {done}" + ); + if done { + ZeroAlphaCleanupPhase::::put(2u8); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + } } 2 => { - let (consumed, removed) = clean_total_hotkey_shares::(); + let (consumed, removed, done) = clean_total_hotkey_shares_batch::(budget); weight = weight.saturating_add(consumed); log::info!( - "Zero-alpha cleanup: TotalHotkeyShares complete. Removed {removed} zero entries." + "Zero-alpha cleanup phase 2 (TotalHotkeyShares): removed {removed} zero entries this batch. Done: {done}" ); - ZeroAlphaCleanupPhase::::put(3u8); - weight = weight.saturating_add(T::DbWeight::get().writes(1)); + if done { + ZeroAlphaCleanupPhase::::put(3u8); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + } } 3 => { - let (consumed, removed) = clean_total_hotkey_alpha_last_epoch::(); + let (consumed, removed, done) = + clean_total_hotkey_alpha_last_epoch_batch::(budget); weight = weight.saturating_add(consumed); log::info!( - "Zero-alpha cleanup: TotalHotkeyAlphaLastEpoch complete. Removed {removed} zero entries." + "Zero-alpha cleanup phase 3 (TotalHotkeyAlphaLastEpoch): removed {removed} zero entries this batch. Done: {done}" ); - ZeroAlphaCleanupPhase::::put(4u8); - weight = weight.saturating_add(T::DbWeight::get().writes(1)); + if done { + ZeroAlphaCleanupPhase::::put(4u8); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + } } 4 => { - let (consumed, removed) = clean_alpha_dividends_per_subnet::(); + let (consumed, removed, done) = + clean_alpha_dividends_per_subnet_batch::(budget); weight = weight.saturating_add(consumed); log::info!( - "Zero-alpha cleanup: AlphaDividendsPerSubnet complete. Removed {removed} zero entries." + "Zero-alpha cleanup phase 4 (AlphaDividendsPerSubnet): removed {removed} zero entries this batch. Done: {done}" ); - - // All phases complete — mark migration as done - HasMigrationRun::::insert(MIGRATION_NAME.to_vec(), true); - ZeroAlphaCleanupPhase::::put(0u8); - weight = weight.saturating_add(T::DbWeight::get().writes(2)); - log::info!("Zero-alpha cleanup: All phases complete. Migration marked as done."); + if done { + // All phases complete — mark migration as done + HasMigrationRun::::insert(MIGRATION_NAME.to_vec(), true); + ZeroAlphaCleanupPhase::::put(0u8); + weight = weight.saturating_add(T::DbWeight::get().writes(2)); + log::info!( + "Zero-alpha cleanup: All phases complete. Migration marked as done." + ); + } } _ => { // Unknown phase, reset @@ -106,103 +124,97 @@ pub fn on_idle_remove_zero_alpha(remaining_weight: Weight) -> Weight weight } -/// Remove all zero-valued entries from Alpha. -/// Returns (weight_consumed, entries_removed). -fn clean_alpha() -> (Weight, u64) { +/// Remove zero-valued entries from Alpha, bounded by weight budget. +/// Returns (weight_consumed, entries_removed, is_done). +fn clean_alpha_batch(budget: Weight) -> (Weight, u64, bool) { + let read_cost = T::DbWeight::get().reads(1); + let write_cost = T::DbWeight::get().writes(1); + let per_entry_max = read_cost.saturating_add(write_cost); let mut weight = Weight::zero(); let mut removed = 0u64; - let to_remove: Vec<_> = Alpha::::iter() - .filter_map(|((hotkey, coldkey, netuid), value)| { - weight = weight.saturating_add(T::DbWeight::get().reads(1)); - if value == 0 { - Some((hotkey, coldkey, netuid)) - } else { - None - } - }) - .collect(); - - for (hotkey, coldkey, netuid) in &to_remove { - Alpha::::remove((hotkey, coldkey, *netuid)); - weight = weight.saturating_add(T::DbWeight::get().writes(1)); - removed = removed.saturating_add(1); + for ((hotkey, coldkey, netuid), value) in Alpha::::iter() { + // Stop if not enough budget for one more entry (read + potential write) + if weight.saturating_add(per_entry_max).any_gt(budget) { + return (weight, removed, false); + } + weight = weight.saturating_add(read_cost); + if value == 0 { + Alpha::::remove((hotkey, coldkey, netuid)); + weight = weight.saturating_add(write_cost); + removed = removed.saturating_add(1); + } } - (weight, removed) + // Iterator exhausted — phase is done + (weight, removed, true) } -/// Remove all zero-valued entries from TotalHotkeyShares. -fn clean_total_hotkey_shares() -> (Weight, u64) { +/// Remove zero-valued entries from TotalHotkeyShares, bounded by weight budget. +fn clean_total_hotkey_shares_batch(budget: Weight) -> (Weight, u64, bool) { + let read_cost = T::DbWeight::get().reads(1); + let write_cost = T::DbWeight::get().writes(1); + let per_entry_max = read_cost.saturating_add(write_cost); let mut weight = Weight::zero(); let mut removed = 0u64; - let to_remove: Vec<_> = TotalHotkeyShares::::iter() - .filter_map(|(hotkey, netuid, value)| { - weight = weight.saturating_add(T::DbWeight::get().reads(1)); - if value == 0 { - Some((hotkey, netuid)) - } else { - None - } - }) - .collect(); - - for (hotkey, netuid) in &to_remove { - TotalHotkeyShares::::remove(hotkey, *netuid); - weight = weight.saturating_add(T::DbWeight::get().writes(1)); - removed = removed.saturating_add(1); + for (hotkey, netuid, value) in TotalHotkeyShares::::iter() { + if weight.saturating_add(per_entry_max).any_gt(budget) { + return (weight, removed, false); + } + weight = weight.saturating_add(read_cost); + if value == 0 { + TotalHotkeyShares::::remove(hotkey, netuid); + weight = weight.saturating_add(write_cost); + removed = removed.saturating_add(1); + } } - (weight, removed) + (weight, removed, true) } -/// Remove all zero-valued entries from TotalHotkeyAlphaLastEpoch. -fn clean_total_hotkey_alpha_last_epoch() -> (Weight, u64) { +/// Remove zero-valued entries from TotalHotkeyAlphaLastEpoch, bounded by weight budget. +fn clean_total_hotkey_alpha_last_epoch_batch(budget: Weight) -> (Weight, u64, bool) { + let read_cost = T::DbWeight::get().reads(1); + let write_cost = T::DbWeight::get().writes(1); + let per_entry_max = read_cost.saturating_add(write_cost); let mut weight = Weight::zero(); let mut removed = 0u64; - let to_remove: Vec<_> = TotalHotkeyAlphaLastEpoch::::iter() - .filter_map(|(hotkey, netuid, value)| { - weight = weight.saturating_add(T::DbWeight::get().reads(1)); - if value.is_zero() { - Some((hotkey, netuid)) - } else { - None - } - }) - .collect(); - - for (hotkey, netuid) in &to_remove { - TotalHotkeyAlphaLastEpoch::::remove(hotkey, *netuid); - weight = weight.saturating_add(T::DbWeight::get().writes(1)); - removed = removed.saturating_add(1); + for (hotkey, netuid, value) in TotalHotkeyAlphaLastEpoch::::iter() { + if weight.saturating_add(per_entry_max).any_gt(budget) { + return (weight, removed, false); + } + weight = weight.saturating_add(read_cost); + if value.is_zero() { + TotalHotkeyAlphaLastEpoch::::remove(hotkey, netuid); + weight = weight.saturating_add(write_cost); + removed = removed.saturating_add(1); + } } - (weight, removed) + (weight, removed, true) } -/// Remove all zero-valued entries from AlphaDividendsPerSubnet. -fn clean_alpha_dividends_per_subnet() -> (Weight, u64) { +/// Remove zero-valued entries from AlphaDividendsPerSubnet, bounded by weight budget. +fn clean_alpha_dividends_per_subnet_batch(budget: Weight) -> (Weight, u64, bool) { + let read_cost = T::DbWeight::get().reads(1); + let write_cost = T::DbWeight::get().writes(1); + let per_entry_max = read_cost.saturating_add(write_cost); let mut weight = Weight::zero(); let mut removed = 0u64; - let to_remove: Vec<_> = AlphaDividendsPerSubnet::::iter() - .filter_map(|(netuid, hotkey, value)| { - weight = weight.saturating_add(T::DbWeight::get().reads(1)); - if value.is_zero() { - Some((netuid, hotkey)) - } else { - None - } - }) - .collect(); - - for (netuid, hotkey) in &to_remove { - AlphaDividendsPerSubnet::::remove(*netuid, hotkey); - weight = weight.saturating_add(T::DbWeight::get().writes(1)); - removed = removed.saturating_add(1); + for (netuid, hotkey, value) in AlphaDividendsPerSubnet::::iter() { + if weight.saturating_add(per_entry_max).any_gt(budget) { + return (weight, removed, false); + } + weight = weight.saturating_add(read_cost); + if value.is_zero() { + AlphaDividendsPerSubnet::::remove(netuid, hotkey); + weight = weight.saturating_add(write_cost); + removed = removed.saturating_add(1); + } } - (weight, removed) + (weight, removed, true) } From c3143d9b58a5d47dbd19a201f6d9baf51ae7feee Mon Sep 17 00:00:00 2001 From: bittoby <218712309+bittoby@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:55:33 +0000 Subject: [PATCH 3/3] Fix cargo fmt formatting in migration --- .../src/migrations/migrate_remove_zero_alpha.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/pallets/subtensor/src/migrations/migrate_remove_zero_alpha.rs b/pallets/subtensor/src/migrations/migrate_remove_zero_alpha.rs index 29710e5d58..ba957dc945 100644 --- a/pallets/subtensor/src/migrations/migrate_remove_zero_alpha.rs +++ b/pallets/subtensor/src/migrations/migrate_remove_zero_alpha.rs @@ -86,8 +86,7 @@ pub fn on_idle_remove_zero_alpha(remaining_weight: Weight) -> Weight } } 3 => { - let (consumed, removed, done) = - clean_total_hotkey_alpha_last_epoch_batch::(budget); + let (consumed, removed, done) = clean_total_hotkey_alpha_last_epoch_batch::(budget); weight = weight.saturating_add(consumed); log::info!( "Zero-alpha cleanup phase 3 (TotalHotkeyAlphaLastEpoch): removed {removed} zero entries this batch. Done: {done}" @@ -98,8 +97,7 @@ pub fn on_idle_remove_zero_alpha(remaining_weight: Weight) -> Weight } } 4 => { - let (consumed, removed, done) = - clean_alpha_dividends_per_subnet_batch::(budget); + let (consumed, removed, done) = clean_alpha_dividends_per_subnet_batch::(budget); weight = weight.saturating_add(consumed); log::info!( "Zero-alpha cleanup phase 4 (AlphaDividendsPerSubnet): removed {removed} zero entries this batch. Done: {done}" @@ -109,9 +107,7 @@ pub fn on_idle_remove_zero_alpha(remaining_weight: Weight) -> Weight HasMigrationRun::::insert(MIGRATION_NAME.to_vec(), true); ZeroAlphaCleanupPhase::::put(0u8); weight = weight.saturating_add(T::DbWeight::get().writes(2)); - log::info!( - "Zero-alpha cleanup: All phases complete. Migration marked as done." - ); + log::info!("Zero-alpha cleanup: All phases complete. Migration marked as done."); } } _ => {