diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index af7c485c57..e7ca045702 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -105,6 +105,8 @@ public class RealmAccess : IDisposable public Realm Realm => ensureUpdateRealm(); + private const string realm_extension = @".realm"; + private Realm ensureUpdateRealm() { if (isSendingNotificationResetEvents) @@ -149,11 +151,18 @@ public RealmAccess(Storage storage, string filename, IDatabaseContextFactory? ef Filename = filename; - const string realm_extension = @".realm"; - if (!Filename.EndsWith(realm_extension, StringComparison.Ordinal)) Filename += realm_extension; + string newerVersionFilename = $"{Filename.Replace(realm_extension, string.Empty)}_newer_version{realm_extension}"; + + // Attempt to recover a newer database version if available. + if (storage.Exists(newerVersionFilename)) + { + Logger.Log(@"A newer realm database has been found, attempting recovery...", LoggingTarget.Database); + attemptRecoverFromFile(newerVersionFilename); + } + try { // This method triggers the first `getRealmInstance` call, which will implicitly run realm migrations and bring the schema up-to-date. @@ -161,15 +170,78 @@ public RealmAccess(Storage storage, string filename, IDatabaseContextFactory? ef } catch (Exception e) { - Logger.Error(e, "Realm startup failed with unrecoverable error; starting with a fresh database. A backup of your database has been made."); + // See https://github.com/realm/realm-core/blob/master/src%2Frealm%2Fobject-store%2Fobject_store.cpp#L1016-L1022 + // This is the best way we can detect a schema version downgrade. + if (e.Message.StartsWith(@"Provided schema version", StringComparison.Ordinal)) + { + Logger.Error(e, "Your local database is too new to work with this version of osu!. Please close osu! and install the latest release to recover your data."); - CreateBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_corrupt{realm_extension}"); - storage.Delete(Filename); + // If a newer version database already exists, don't backup again. We can presume that the first backup is the one we care about. + if (!storage.Exists(newerVersionFilename)) + CreateBackup(newerVersionFilename); + + storage.Delete(Filename); + } + else + { + Logger.Error(e, "Realm startup failed with unrecoverable error; starting with a fresh database. A backup of your database has been made."); + CreateBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_corrupt{realm_extension}"); + storage.Delete(Filename); + } cleanupPendingDeletions(); } } + private void attemptRecoverFromFile(string recoveryFilename) + { + Logger.Log($@"Performing recovery from {recoveryFilename}", LoggingTarget.Database); + + // First check the user hasn't started to use the database that is in place.. + try + { + using (var realm = Realm.GetInstance(getConfiguration())) + { + if (realm.All().Any()) + { + Logger.Log(@"Recovery aborted as the existing database has scores set already.", LoggingTarget.Database); + Logger.Log(@"To perform recovery, delete client.realm while osu! is not running.", LoggingTarget.Database); + return; + } + } + } + catch + { + // Even if reading the in place database fails, still attempt to recover. + } + + // Then check that the database we are about to attempt recovery can actually be recovered on this version.. + try + { + using (var realm = Realm.GetInstance(getConfiguration(recoveryFilename))) + { + // Don't need to do anything, just check that opening the realm works correctly. + } + } + catch + { + Logger.Log(@"Recovery aborted as the newer version could not be loaded by this osu! version.", LoggingTarget.Database); + return; + } + + // For extra safety, also store the temporarily-used database which we are about to replace. + CreateBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_newer_version_before_recovery{realm_extension}"); + + storage.Delete(Filename); + + using (var inputStream = storage.GetStream(recoveryFilename)) + using (var outputStream = storage.GetStream(Filename, FileAccess.Write, FileMode.Create)) + inputStream.CopyTo(outputStream); + + storage.Delete(recoveryFilename); + Logger.Log(@"Recovery complete!", LoggingTarget.Database); + } + private void cleanupPendingDeletions() { using (var realm = getRealmInstance()) @@ -476,7 +548,7 @@ private Realm getRealmInstance() } } - private RealmConfiguration getConfiguration() + private RealmConfiguration getConfiguration(string? filename = null) { // This is currently the only usage of temporary files at the osu! side. // If we use the temporary folder in more situations in the future, this should be moved to a higher level (helper method or OsuGameBase). @@ -484,7 +556,7 @@ private RealmConfiguration getConfiguration() if (!Directory.Exists(tempPathLocation)) Directory.CreateDirectory(tempPathLocation); - return new RealmConfiguration(storage.GetFullPath(Filename, true)) + return new RealmConfiguration(storage.GetFullPath(filename ?? Filename, true)) { SchemaVersion = schema_version, MigrationCallback = onMigration,