-
Notifications
You must be signed in to change notification settings - Fork 19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(backend): User data migration #1967
Changes from all commits
752ff36
fdfa3a5
0e5b02c
b81b4d4
c77f63a
c58cf2e
a25d707
0dab5ca
523443c
27e6a38
76a51b3
68a97ad
2166b03
7081e51
2b12c2b
aa74aa0
0e18602
41defe2
1c6326c
7ba4254
5ea2c85
5721d59
24f4ee0
c25acae
0bd7b09
289e2e7
16f5bb9
bc9f6db
ede49a7
f9d1036
a1acecf
40a1b44
0affb89
38b72c4
4ea35a9
4954fad
0865dfd
dc9812e
210d830
2e67058
6ea186f
f7089a2
bcc627c
2a723b1
8cedc9b
ad8947d
232c51d
6a00d1f
7ce9ece
fa9f89c
f693595
04492af
3db4c35
67fe0f0
546457f
d618610
d16859d
29974a6
05469d4
61bf47c
28870a2
c1c6156
755b87f
ed2ace4
90f7989
2e41636
e1e1a01
8f6c993
e7b8038
3807a3e
9f49c1c
fa3e350
1f2796e
de2ac41
786f5dd
ab37aee
487a7fb
adcf8d5
30232af
0212b35
1edb370
a0bfaf5
5b0286b
a3a5b13
34e66df
4c2f34d
b7bb5e3
e46dc86
9722353
89ff8f4
93d56ac
1007600
bedc1d7
3b4b7b4
e97abdd
0a6bb37
d90c276
363b81e
8d245a1
94a9188
3a89b0f
fb145a2
d80d3e2
c035175
8a26936
f31c9a6
497bfa7
bf35cfc
3faf818
9a81587
90a35f0
982c26b
7c017ea
eecf1b9
27303c8
acc8b4f
03b041a
bdc0199
9bd5a81
f90f2fc
4111d83
0581341
c68cb28
003c3d6
5e91a36
a3b0a4d
83508c1
387414f
200d559
c4524d4
8f003c9
c131652
4738e5d
b04a254
b39aa93
2e27f4d
1d79a70
bac26de
8dcd5df
8ee070e
79fb60b
7cb713b
12a478f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,13 +2,23 @@ use crate::{ | |
mutate_state, read_state, | ||
types::{Candid, StoredPrincipal}, | ||
}; | ||
use candid::{decode_one, CandidType, Principal}; | ||
use candid::{decode_one, encode_one, CandidType, Principal}; | ||
use ic_cdk::eprintln; | ||
use ic_cdk_timers::clear_timer; | ||
use serde::Deserialize; | ||
use shared::types::{ | ||
custom_token::CustomToken, token::UserToken, user_profile::StoredUserProfile, | ||
MigrationProgress, Timestamp, | ||
use shared::{ | ||
backend_api::Service, | ||
types::{ | ||
custom_token::CustomToken, token::UserToken, user_profile::StoredUserProfile, | ||
MigrationError, MigrationProgress, Timestamp, | ||
}, | ||
}; | ||
use std::ops::Bound; | ||
use steps::{ | ||
assert_target_empty, assert_target_has_all_data, lock_migration_target, make_this_readonly, | ||
unlock_local, unlock_target, | ||
}; | ||
pub mod steps; | ||
|
||
/// A chunk of data to be migrated. | ||
/// | ||
|
@@ -67,8 +77,110 @@ pub fn bulk_up(data: &[u8]) { | |
} | ||
} | ||
|
||
#[allow(clippy::unused_async)] // TODO: remove this once we make real function calls | ||
pub async fn step_migration() { | ||
/// The next chunk of user tokens to be migrated. | ||
fn next_user_token_chunk(last_user_token: Option<Principal>) -> Vec<(Principal, Vec<UserToken>)> { | ||
let chunk_size = 5; | ||
let range = last_user_token.map_or((Bound::Unbounded, Bound::Unbounded), |token| { | ||
(Bound::Excluded(StoredPrincipal(token)), Bound::Unbounded) | ||
}); | ||
read_state(|state| { | ||
state | ||
.user_token | ||
.range(range) | ||
.take(chunk_size) | ||
.map(|(stored_principal, token)| (stored_principal.0, token.0)) | ||
.collect::<Vec<_>>() | ||
}) | ||
} | ||
|
||
/// The next chunk of custom tokens to be migrated. | ||
fn next_custom_token_chunk( | ||
last_custom_token: Option<Principal>, | ||
) -> Vec<(Principal, Vec<CustomToken>)> { | ||
let chunk_size = 5; | ||
let range = last_custom_token.map_or((Bound::Unbounded, Bound::Unbounded), |token| { | ||
(Bound::Excluded(StoredPrincipal(token)), Bound::Unbounded) | ||
}); | ||
read_state(|state| { | ||
state | ||
.custom_token | ||
.range(range) | ||
.take(chunk_size) | ||
.map(|(stored_principal, token)| (stored_principal.0, token.0)) | ||
.collect::<Vec<_>>() | ||
}) | ||
} | ||
|
||
/// The next chunk of user profiles to be migrated. | ||
fn next_user_profile_chunk( | ||
last_user_profile: Option<(Timestamp, Principal)>, | ||
) -> Vec<((Timestamp, Principal), StoredUserProfile)> { | ||
let chunk_size = 5; | ||
let range = last_user_profile.map_or( | ||
(Bound::Unbounded, Bound::Unbounded), | ||
|(timestamp, principal)| { | ||
( | ||
Bound::Excluded((timestamp, StoredPrincipal(principal))), | ||
Bound::Unbounded, | ||
) | ||
}, | ||
); | ||
read_state(|state| { | ||
state | ||
.user_profile | ||
.range(range) | ||
.take(chunk_size) | ||
.map(|((timestamp, stored_principal), profile)| { | ||
((timestamp, stored_principal.0), profile.0) | ||
}) | ||
.collect::<Vec<_>>() | ||
}) | ||
} | ||
|
||
/// The next chunk of user timestamps to be migrated. | ||
fn next_user_timestamp_chunk(user_maybe: Option<Principal>) -> Vec<(Principal, Timestamp)> { | ||
let chunk_size = 5; | ||
let range = user_maybe.map_or((Bound::Unbounded, Bound::Unbounded), |user| { | ||
(Bound::Excluded(StoredPrincipal(user)), Bound::Unbounded) | ||
}); | ||
read_state(|state| { | ||
state | ||
.user_profile_updated | ||
.range(range) | ||
.take(chunk_size) | ||
.map(|(stored_principal, timestamp)| (stored_principal.0, timestamp)) | ||
.collect::<Vec<_>>() | ||
}) | ||
} | ||
|
||
/// Migrates a chunk of data. | ||
/// | ||
/// # Returns | ||
/// The updated progress. | ||
/// | ||
/// # Errors | ||
/// - Throws a `MigrationError::DataMigrationFailed` if the data transfer fails. | ||
macro_rules! migrate { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interesting. I would have approached that using generics, but this is probably cleaner. 👍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think generics are easier to debug than macros and more familiar to most people. So I tend to avoid macros where possible. But I am not sure it is possible to pass say |
||
($migration:ident, $chunk:ident, $progress_variant:ident, $chunk_variant:ident) => {{ | ||
let last = $chunk.last().map(|(k, _)| k).cloned(); | ||
let next_state = last | ||
.map(|last| MigrationProgress::$progress_variant(Some(last))) | ||
.unwrap_or_else(|| $migration.progress.next()); | ||
let migration_data = MigrationChunk::$chunk_variant($chunk); | ||
let migration_bytes = encode_one(migration_data).expect("failed to encode migration data"); | ||
Service($migration.to) | ||
.bulk_up(migration_bytes) | ||
.await | ||
.map_err(|e| { | ||
eprintln!("Failed to transfer data {e:?}"); | ||
MigrationError::DataMigrationFailed | ||
})?; | ||
next_state | ||
}}; | ||
} | ||
pub(crate) use migrate; | ||
|
||
pub async fn step_migration() -> Result<MigrationProgress, MigrationError> { | ||
fn set_progress(progress: MigrationProgress) { | ||
mutate_state(|state| { | ||
state.migration.iter_mut().for_each(|migration| { | ||
|
@@ -78,60 +190,60 @@ pub async fn step_migration() { | |
} | ||
let migration = read_state(|s| s.migration.clone()); | ||
let progress = match migration { | ||
Some(migration) => { | ||
match migration.progress { | ||
MigrationProgress::Pending => { | ||
// TODO: Lock the local canister APIs. | ||
migration.progress.next() | ||
} | ||
MigrationProgress::LockingTarget => { | ||
// TODO: Lock the target canister APIs. | ||
migration.progress.next() | ||
} | ||
MigrationProgress::CheckingTarget => { | ||
// TODO: Check that the target canister is empty. | ||
migration.progress.next() | ||
} | ||
MigrationProgress::MigratedUserTokensUpTo(_last) => { | ||
// TODO: Migrate user tokens, then move on to the next stage. | ||
migration.progress.next() | ||
} | ||
MigrationProgress::MigratedCustomTokensUpTo(_last_custom_token) => { | ||
// TODO: Migrate custom tokens, then move on to the next stage. | ||
migration.progress.next() | ||
} | ||
MigrationProgress::MigratedUserTimestampsUpTo(_user_maybe) => { | ||
// TODO: Migrate user timestamps, then move on to the next stage. | ||
migration.progress.next() | ||
} | ||
MigrationProgress::MigratedUserProfilesUpTo(_last_user_profile) => { | ||
// TODO: Migrate user profiles, then move on to the next stage. | ||
migration.progress.next() | ||
} | ||
MigrationProgress::CheckingDataMigration => { | ||
// TODO: Check that the target canister has all the data. | ||
migration.progress.next() | ||
} | ||
MigrationProgress::UnlockingTarget => { | ||
// TODO: Unlock user profiles in the target canister | ||
migration.progress.next() | ||
} | ||
MigrationProgress::Unlocking => { | ||
// TODO: Unlock signing in this canister | ||
migration.progress.next() | ||
} | ||
// TODO: Add steps to unlock APIs. | ||
MigrationProgress::Completed => { | ||
// Migration is complete. | ||
clear_timer(migration.timer_id); | ||
migration.progress.next() | ||
} | ||
MigrationProgress::Failed(_) => ic_cdk::trap("Migration error."), | ||
Some(migration) => match migration.progress { | ||
MigrationProgress::Pending => { | ||
make_this_readonly(); | ||
migration.progress.next() | ||
} | ||
} | ||
None => { | ||
ic_cdk::trap("migration is not in progress"); | ||
} | ||
MigrationProgress::LockingTarget => { | ||
lock_migration_target(&migration).await?; | ||
migration.progress.next() | ||
} | ||
MigrationProgress::CheckingTarget => { | ||
assert_target_empty(&migration).await?; | ||
migration.progress.next() | ||
} | ||
MigrationProgress::MigratedUserTokensUpTo(last) => { | ||
let chunk = next_user_token_chunk(last); | ||
migrate!(migration, chunk, MigratedUserTokensUpTo, UserToken) | ||
} | ||
MigrationProgress::MigratedCustomTokensUpTo(last_custom_token) => { | ||
let chunk = next_custom_token_chunk(last_custom_token); | ||
migrate!(migration, chunk, MigratedCustomTokensUpTo, CustomToken) | ||
} | ||
MigrationProgress::MigratedUserTimestampsUpTo(user_maybe) => { | ||
let chunk = next_user_timestamp_chunk(user_maybe); | ||
migrate!( | ||
migration, | ||
chunk, | ||
MigratedUserTimestampsUpTo, | ||
UserProfileUpdated | ||
) | ||
} | ||
MigrationProgress::MigratedUserProfilesUpTo(last_user_profile) => { | ||
let chunk = next_user_profile_chunk(last_user_profile); | ||
migrate!(migration, chunk, MigratedUserProfilesUpTo, UserProfile) | ||
} | ||
MigrationProgress::CheckingDataMigration => { | ||
assert_target_has_all_data(&migration).await?; | ||
migration.progress.next() | ||
} | ||
MigrationProgress::UnlockingTarget => { | ||
unlock_target(&migration).await?; | ||
migration.progress.next() | ||
} | ||
MigrationProgress::Unlocking => { | ||
unlock_local(); | ||
migration.progress.next() | ||
} | ||
MigrationProgress::Completed => { | ||
clear_timer(migration.timer_id); | ||
migration.progress.next() | ||
} | ||
MigrationProgress::Failed(e) => return Err(e), | ||
}, | ||
None => return Err(MigrationError::NoMigrationInProgress), | ||
}; | ||
set_progress(progress); | ||
Ok(progress) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
use crate::{modify_state_config, mutate_state}; | ||
use ic_cdk::eprintln; | ||
use shared::{ | ||
backend_api::Service, | ||
types::{ApiEnabled, Guards, Migration, MigrationError, Stats}, | ||
}; | ||
|
||
/// Makes the local canister APIs readonly. | ||
pub fn make_this_readonly() { | ||
mutate_state(|state| { | ||
modify_state_config(state, |config| { | ||
config.api = Some(Guards { | ||
threshold_key: ApiEnabled::ReadOnly, | ||
user_data: ApiEnabled::ReadOnly, | ||
}); | ||
}); | ||
}); | ||
} | ||
|
||
/// Locks the migration target canister APIs. | ||
pub async fn lock_migration_target(migration: &Migration) -> Result<(), MigrationError> { | ||
Service(migration.to) | ||
.set_guards(Guards { | ||
threshold_key: ApiEnabled::Disabled, | ||
user_data: ApiEnabled::Disabled, | ||
}) | ||
.await | ||
.map_err(|e| { | ||
eprintln!("Failed to lock target canister: {:?}", e); | ||
MigrationError::TargetLockFailed | ||
}) | ||
} | ||
|
||
/// Verifies that there is no data in the migration target canister. | ||
pub async fn assert_target_empty(migration: &Migration) -> Result<(), MigrationError> { | ||
let stats = Service(migration.to) | ||
.stats() | ||
.await | ||
.map_err(|e| { | ||
eprintln!("Failed to get stats from the target canister: {:?}", e); | ||
MigrationError::CouldNotGetTargetPriorStats | ||
})? | ||
.0; | ||
if stats != Stats::default() { | ||
return Err(MigrationError::TargetCanisterNotEmpty(stats)); | ||
} | ||
Ok(()) | ||
} | ||
|
||
/// Verifies that the target canister has all the data. | ||
pub async fn assert_target_has_all_data(migration: &Migration) -> Result<(), MigrationError> { | ||
let source_stats = crate::stats(); | ||
let target_stats = Service(migration.to) | ||
.stats() | ||
.await | ||
.map_err(|e| { | ||
eprintln!("Failed to get stats from the target canister: {e:?}"); | ||
MigrationError::CouldNotGetTargetPostStats | ||
})? | ||
.0; | ||
if source_stats != target_stats { | ||
return Err(MigrationError::TargetStatsMismatch( | ||
source_stats, | ||
target_stats, | ||
)); | ||
} | ||
Ok(()) | ||
} | ||
|
||
/// Unlocks the target canister APIs. | ||
pub async fn unlock_target(migration: &Migration) -> Result<(), MigrationError> { | ||
Service(migration.to) | ||
.set_guards(Guards { | ||
threshold_key: ApiEnabled::Disabled, | ||
user_data: ApiEnabled::Enabled, | ||
}) | ||
.await | ||
.map_err(|e| { | ||
eprintln!("Failed to unlock target canister: {e:?}"); | ||
MigrationError::TargetUnlockFailed | ||
}) | ||
} | ||
|
||
/// Unlocks the local signing APIs. Disables local user data APIs. | ||
pub fn unlock_local() { | ||
mutate_state(|state| { | ||
modify_state_config(state, |config| { | ||
config.api = Some(Guards { | ||
threshold_key: ApiEnabled::Enabled, | ||
user_data: ApiEnabled::Disabled, | ||
}); | ||
}); | ||
}); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a very small chunk size. Will this be increase / made configurable in the future?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oisy doesn't have many users. In future I expect this code to be gone, at which point all the migration code that exists will be infinitely configurable. 😉