Add flow to allow recovery after running an older release (with a different realm database version)

As brought up in https://github.com/ppy/osu/discussions/17148
This commit is contained in:
Dean Herbert 2022-03-08 16:06:42 +09:00
parent 719331420c
commit 0718a55ad0
1 changed files with 79 additions and 7 deletions

View File

@ -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<ScoreInfo>().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,