Merge branch 'master' into fix-skin-flow

This commit is contained in:
Dan Balasescu
2022-09-16 20:15:51 +09:00
committed by GitHub
150 changed files with 1318 additions and 15090 deletions

View File

@ -1,218 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.IO;
using System.Linq;
using System.Threading;
using Microsoft.EntityFrameworkCore.Storage;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Statistics;
namespace osu.Game.Database
{
public class DatabaseContextFactory : IDatabaseContextFactory
{
private readonly Storage storage;
public const string DATABASE_NAME = @"client.db";
private ThreadLocal<OsuDbContext> threadContexts;
private readonly object writeLock = new object();
private bool currentWriteDidWrite;
private bool currentWriteDidError;
private int currentWriteUsages;
private IDbContextTransaction currentWriteTransaction;
public DatabaseContextFactory(Storage storage)
{
this.storage = storage;
recycleThreadContexts();
}
private static readonly GlobalStatistic<int> reads = GlobalStatistics.Get<int>("Database", "Get (Read)");
private static readonly GlobalStatistic<int> writes = GlobalStatistics.Get<int>("Database", "Get (Write)");
private static readonly GlobalStatistic<int> commits = GlobalStatistics.Get<int>("Database", "Commits");
private static readonly GlobalStatistic<int> rollbacks = GlobalStatistics.Get<int>("Database", "Rollbacks");
/// <summary>
/// Get a context for the current thread for read-only usage.
/// If a <see cref="DatabaseWriteUsage"/> is in progress, the existing write-safe context will be returned.
/// </summary>
public OsuDbContext Get()
{
reads.Value++;
return threadContexts.Value;
}
/// <summary>
/// Request a context for write usage. Can be consumed in a nested fashion (and will return the same underlying context).
/// This method may block if a write is already active on a different thread.
/// </summary>
/// <param name="withTransaction">Whether to start a transaction for this write.</param>
/// <returns>A usage containing a usable context.</returns>
public DatabaseWriteUsage GetForWrite(bool withTransaction = true)
{
writes.Value++;
Monitor.Enter(writeLock);
OsuDbContext context;
try
{
if (currentWriteTransaction == null && withTransaction)
{
// this mitigates the fact that changes on tracked entities will not be rolled back with the transaction by ensuring write operations are always executed in isolated contexts.
// if this results in sub-optimal efficiency, we may need to look into removing Database-level transactions in favour of running SaveChanges where we currently commit the transaction.
if (threadContexts.IsValueCreated)
recycleThreadContexts();
context = threadContexts.Value;
currentWriteTransaction = context.Database.BeginTransaction();
}
else
{
// we want to try-catch the retrieval of the context because it could throw an error (in CreateContext).
context = threadContexts.Value;
}
}
catch
{
// retrieval of a context could trigger a fatal error.
Monitor.Exit(writeLock);
throw;
}
Interlocked.Increment(ref currentWriteUsages);
return new DatabaseWriteUsage(context, usageCompleted) { IsTransactionLeader = currentWriteTransaction != null && currentWriteUsages == 1 };
}
private void usageCompleted(DatabaseWriteUsage usage)
{
int usages = Interlocked.Decrement(ref currentWriteUsages);
try
{
currentWriteDidWrite |= usage.PerformedWrite;
currentWriteDidError |= usage.Errors.Any();
if (usages == 0)
{
if (currentWriteDidError)
{
rollbacks.Value++;
currentWriteTransaction?.Rollback();
}
else
{
commits.Value++;
currentWriteTransaction?.Commit();
}
if (currentWriteDidWrite || currentWriteDidError)
{
// explicitly dispose to ensure any outstanding flushes happen as soon as possible (and underlying resources are purged).
usage.Context.Dispose();
// once all writes are complete, we want to refresh thread-specific contexts to make sure they don't have stale local caches.
recycleThreadContexts();
}
currentWriteTransaction = null;
currentWriteDidWrite = false;
currentWriteDidError = false;
}
}
finally
{
Monitor.Exit(writeLock);
}
}
private void recycleThreadContexts()
{
// Contexts for other threads are not disposed as they may be in use elsewhere. Instead, fresh contexts are exposed
// for other threads to use, and we rely on the finalizer inside OsuDbContext to handle their previous contexts
threadContexts?.Value.Dispose();
threadContexts = new ThreadLocal<OsuDbContext>(CreateContext, true);
}
protected virtual OsuDbContext CreateContext() => new OsuDbContext(CreateDatabaseConnectionString(DATABASE_NAME, storage))
{
Database = { AutoTransactionsEnabled = false }
};
public void CreateBackup(string backupFilename)
{
Logger.Log($"Creating full EF database backup at {backupFilename}", LoggingTarget.Database);
using (var source = storage.GetStream(DATABASE_NAME, mode: FileMode.Open))
using (var destination = storage.GetStream(backupFilename, FileAccess.Write, FileMode.CreateNew))
source.CopyTo(destination);
}
public void ResetDatabase()
{
lock (writeLock)
{
recycleThreadContexts();
try
{
int attempts = 10;
// Retry logic taken from MigratableStorage.AttemptOperation.
while (true)
{
try
{
storage.Delete(DATABASE_NAME);
return;
}
catch (Exception)
{
if (attempts-- == 0)
throw;
}
Thread.Sleep(250);
}
}
catch
{
// for now we are not sure why file handles are kept open by EF, but this is generally only used in testing
}
}
}
public void FlushConnections()
{
if (threadContexts != null)
{
foreach (var context in threadContexts.Values)
context.Dispose();
}
recycleThreadContexts();
}
public static string CreateDatabaseConnectionString(string filename, Storage storage) => string.Concat("Data Source=", storage.GetFullPath($@"{filename}", true));
private readonly ManualResetEventSlim migrationComplete = new ManualResetEventSlim();
public void SetMigrationCompletion() => migrationComplete.Set();
public void WaitForMigrationCompletion()
{
if (!migrationComplete.Wait(300000))
throw new TimeoutException("Migration took too long (likely stuck).");
}
}
}

View File

@ -1,60 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Collections.Generic;
namespace osu.Game.Database
{
public class DatabaseWriteUsage : IDisposable
{
public readonly OsuDbContext Context;
private readonly Action<DatabaseWriteUsage> usageCompleted;
public DatabaseWriteUsage(OsuDbContext context, Action<DatabaseWriteUsage> onCompleted)
{
Context = context;
usageCompleted = onCompleted;
}
public bool PerformedWrite { get; private set; }
private bool isDisposed;
public List<Exception> Errors = new List<Exception>();
/// <summary>
/// Whether this write usage will commit a transaction on completion.
/// If false, there is a parent usage responsible for transaction commit.
/// </summary>
public bool IsTransactionLeader;
protected void Dispose(bool disposing)
{
if (isDisposed) return;
isDisposed = true;
try
{
PerformedWrite |= Context.SaveChanges() > 0;
}
catch (Exception e)
{
Errors.Add(e);
throw;
}
finally
{
usageCompleted?.Invoke(this);
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -1,595 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Development;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Models;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets;
using osu.Game.Scoring;
using osu.Game.Skinning;
using osuTK;
using Realms;
using SharpCompress.Archives;
using SharpCompress.Archives.Zip;
using SharpCompress.Common;
using SharpCompress.Writers.Zip;
namespace osu.Game.Database
{
internal class EFToRealmMigrator : CompositeDrawable
{
public Task<bool> MigrationCompleted => migrationCompleted.Task;
private readonly TaskCompletionSource<bool> migrationCompleted = new TaskCompletionSource<bool>();
[Resolved]
private DatabaseContextFactory efContextFactory { get; set; } = null!;
[Resolved]
private RealmAccess realm { get; set; } = null!;
[Resolved]
private OsuConfigManager config { get; set; } = null!;
[Resolved]
private INotificationOverlay notificationOverlay { get; set; } = null!;
[Resolved]
private OsuGame game { get; set; } = null!;
[Resolved]
private Storage storage { get; set; } = null!;
private readonly OsuTextFlowContainer currentOperationText;
public EFToRealmMigrator()
{
RelativeSizeAxes = Axes.Both;
InternalChildren = new Drawable[]
{
new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Spacing = new Vector2(10),
Children = new Drawable[]
{
new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "Database migration in progress",
Font = OsuFont.Default.With(size: 40)
},
new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "This could take a few minutes depending on the speed of your disk(s).",
Font = OsuFont.Default.With(size: 30)
},
new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "Please keep the window open until this completes!",
Font = OsuFont.Default.With(size: 30)
},
new LoadingSpinner(true)
{
State = { Value = Visibility.Visible }
},
currentOperationText = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 30))
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
TextAnchor = Anchor.TopCentre,
},
}
},
};
}
protected override void LoadComplete()
{
base.LoadComplete();
beginMigration();
}
private void beginMigration()
{
const string backup_folder = "backups";
string backupSuffix = $"before_final_migration_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
// required for initial backup.
var realmBlockOperations = realm.BlockAllOperations("EF migration");
Task.Factory.StartNew(() =>
{
try
{
realm.CreateBackup(Path.Combine(backup_folder, $"client.{backupSuffix}.realm"));
}
finally
{
// Once the backup is created, we need to stop blocking operations so the migration can complete.
realmBlockOperations.Dispose();
// Clean up here so we don't accidentally dispose twice.
realmBlockOperations = null;
}
efContextFactory.CreateBackup(Path.Combine(backup_folder, $"client.{backupSuffix}.db"));
using (var ef = efContextFactory.Get())
{
realm.Write(r =>
{
// Before beginning, ensure realm is in an empty state.
// Migrations which are half-completed could lead to issues if the user tries a second time.
// Note that we only do this for beatmaps and scores since the other migrations are yonks old.
r.RemoveAll<BeatmapSetInfo>();
r.RemoveAll<BeatmapInfo>();
r.RemoveAll<BeatmapMetadata>();
r.RemoveAll<ScoreInfo>();
});
ef.Migrate();
migrateSettings(ef);
migrateSkins(ef);
migrateBeatmaps(ef);
migrateScores(ef);
}
}, TaskCreationOptions.LongRunning).ContinueWith(t =>
{
if (t.Exception == null)
{
log("Migration successful!");
if (DebugUtils.IsDebugBuild)
{
Logger.Log(
"Your development database has been fully migrated to realm. If you switch back to a pre-realm branch and need your previous database, rename the backup file back to \"client.db\".\n\nNote that doing this can potentially leave your file store in a bad state.",
level: LogLevel.Important);
}
}
else
{
log("Migration failed!");
Logger.Log(t.Exception.ToString(), LoggingTarget.Database);
if (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && t.Exception.Flatten().InnerException is TypeInitializationException)
{
// Not guaranteed to be the only cause of exception, but let's roll with it for now.
log("Please download and run the intel version of osu! once\nto allow data migration to complete!");
efContextFactory.SetMigrationCompletion();
return;
}
notificationOverlay.Post(new SimpleErrorNotification
{
Text =
"IMPORTANT: During data migration, some of your data could not be successfully migrated. The previous version has been backed up.\n\nFor further assistance, please open a discussion on github and attach your backup files (click to get started).",
Activated = () =>
{
game.OpenUrlExternally(
$@"https://github.com/ppy/osu/discussions/new?title=Realm%20migration%20issue ({t.Exception.Message})&body=Please%20drag%20the%20""attach_me.zip""%20file%20here!&category=q-a",
true);
const string attachment_filename = "attach_me.zip";
var backupStorage = storage.GetStorageForDirectory(backup_folder);
backupStorage.Delete(attachment_filename);
try
{
using (var zip = ZipArchive.Create())
{
zip.AddAllFromDirectory(backupStorage.GetFullPath(string.Empty));
zip.SaveTo(Path.Combine(backupStorage.GetFullPath(string.Empty), attachment_filename), new ZipWriterOptions(CompressionType.Deflate));
}
}
catch { }
backupStorage.PresentFileExternally(attachment_filename);
return true;
}
});
}
// Regardless of success, since the game is going to continue with startup let's move the ef database out of the way.
// If we were to not do this, the migration would run another time the next time the user starts the game.
deletePreRealmData();
// If something went wrong and the disposal token wasn't invoked above, ensure it is here.
realmBlockOperations?.Dispose();
migrationCompleted.SetResult(true);
efContextFactory.SetMigrationCompletion();
});
}
private void deletePreRealmData()
{
// Delete the database permanently.
// Will cause future startups to not attempt migration.
efContextFactory.ResetDatabase();
}
private void log(string message)
{
Logger.Log(message, LoggingTarget.Database);
Scheduler.AddOnce(m => currentOperationText.Text = m, message);
}
private void migrateBeatmaps(OsuDbContext ef)
{
// can be removed 20220730.
var existingBeatmapSets = ef.EFBeatmapSetInfo
.Include(s => s.Beatmaps).ThenInclude(b => b.RulesetInfo)
.Include(s => s.Beatmaps).ThenInclude(b => b.Metadata)
.Include(s => s.Beatmaps).ThenInclude(b => b.BaseDifficulty)
.Include(s => s.Files).ThenInclude(f => f.FileInfo)
.Include(s => s.Metadata)
.AsSplitQuery();
log("Beginning beatmaps migration to realm");
// previous entries in EF are removed post migration.
if (!existingBeatmapSets.Any())
{
log("No beatmaps found to migrate");
return;
}
int count = existingBeatmapSets.Count();
realm.Run(r =>
{
log($"Found {count} beatmaps in EF");
var transaction = r.BeginWrite();
int written = 0;
int missing = 0;
try
{
foreach (var beatmapSet in existingBeatmapSets)
{
if (++written % 1000 == 0)
{
transaction.Commit();
transaction = r.BeginWrite();
log($"Migrated {written}/{count} beatmaps...");
}
var realmBeatmapSet = new BeatmapSetInfo
{
OnlineID = beatmapSet.OnlineID ?? -1,
DateAdded = beatmapSet.DateAdded,
Status = beatmapSet.Status,
DeletePending = beatmapSet.DeletePending,
Hash = beatmapSet.Hash,
Protected = beatmapSet.Protected,
};
migrateFiles(beatmapSet, r, realmBeatmapSet);
foreach (var beatmap in beatmapSet.Beatmaps)
{
var ruleset = r.Find<RulesetInfo>(beatmap.RulesetInfo.ShortName);
var metadata = getBestMetadata(beatmap.Metadata, beatmapSet.Metadata);
if (ruleset == null)
{
log($"Skipping {++missing} beatmaps with missing ruleset");
continue;
}
var realmBeatmap = new BeatmapInfo(ruleset, new BeatmapDifficulty(beatmap.BaseDifficulty), metadata)
{
DifficultyName = beatmap.DifficultyName,
Status = beatmap.Status,
OnlineID = beatmap.OnlineID ?? -1,
Length = beatmap.Length,
BPM = beatmap.BPM,
Hash = beatmap.Hash,
StarRating = beatmap.StarRating,
MD5Hash = beatmap.MD5Hash,
Hidden = beatmap.Hidden,
AudioLeadIn = beatmap.AudioLeadIn,
StackLeniency = beatmap.StackLeniency,
SpecialStyle = beatmap.SpecialStyle,
LetterboxInBreaks = beatmap.LetterboxInBreaks,
WidescreenStoryboard = beatmap.WidescreenStoryboard,
EpilepsyWarning = beatmap.EpilepsyWarning,
SamplesMatchPlaybackRate = beatmap.SamplesMatchPlaybackRate,
DistanceSpacing = beatmap.DistanceSpacing,
BeatDivisor = beatmap.BeatDivisor,
GridSize = beatmap.GridSize,
TimelineZoom = beatmap.TimelineZoom,
Countdown = beatmap.Countdown,
CountdownOffset = beatmap.CountdownOffset,
Bookmarks = beatmap.Bookmarks,
BeatmapSet = realmBeatmapSet,
};
realmBeatmapSet.Beatmaps.Add(realmBeatmap);
}
r.Add(realmBeatmapSet);
}
}
finally
{
transaction.Commit();
}
log($"Successfully migrated {count} beatmaps to realm");
});
}
private BeatmapMetadata getBestMetadata(EFBeatmapMetadata? beatmapMetadata, EFBeatmapMetadata? beatmapSetMetadata)
{
var metadata = beatmapMetadata ?? beatmapSetMetadata ?? new EFBeatmapMetadata();
return new BeatmapMetadata
{
Title = metadata.Title,
TitleUnicode = metadata.TitleUnicode,
Artist = metadata.Artist,
ArtistUnicode = metadata.ArtistUnicode,
Author =
{
OnlineID = metadata.Author.Id,
Username = metadata.Author.Username,
},
Source = metadata.Source,
Tags = metadata.Tags,
PreviewTime = metadata.PreviewTime,
AudioFile = metadata.AudioFile,
BackgroundFile = metadata.BackgroundFile,
};
}
private void migrateScores(OsuDbContext db)
{
// can be removed 20220730.
var existingScores = db.ScoreInfo
.Include(s => s.Ruleset)
.Include(s => s.BeatmapInfo)
.Include(s => s.Files)
.ThenInclude(f => f.FileInfo)
.AsSplitQuery();
log("Beginning scores migration to realm");
// previous entries in EF are removed post migration.
if (!existingScores.Any())
{
log("No scores found to migrate");
return;
}
int count = existingScores.Count();
realm.Run(r =>
{
log($"Found {count} scores in EF");
var transaction = r.BeginWrite();
int written = 0;
int missing = 0;
try
{
foreach (var score in existingScores)
{
if (++written % 1000 == 0)
{
transaction.Commit();
transaction = r.BeginWrite();
log($"Migrated {written}/{count} scores...");
}
var beatmap = r.All<BeatmapInfo>().FirstOrDefault(b => b.Hash == score.BeatmapInfo.Hash);
var ruleset = r.Find<RulesetInfo>(score.Ruleset.ShortName);
if (beatmap == null || ruleset == null)
{
log($"Skipping {++missing} scores with missing ruleset or beatmap");
continue;
}
var user = new RealmUser
{
OnlineID = score.User.OnlineID,
Username = score.User.Username
};
var realmScore = new ScoreInfo(beatmap, ruleset, user)
{
Hash = score.Hash,
DeletePending = score.DeletePending,
OnlineID = score.OnlineID ?? -1,
ModsJson = score.ModsJson,
StatisticsJson = score.StatisticsJson,
TotalScore = score.TotalScore,
MaxCombo = score.MaxCombo,
Accuracy = score.Accuracy,
Date = score.Date,
PP = score.PP,
Rank = score.Rank,
HitEvents = score.HitEvents,
Passed = score.Passed,
Combo = score.Combo,
Position = score.Position,
Statistics = score.Statistics,
Mods = score.Mods,
APIMods = score.APIMods,
};
migrateFiles(score, r, realmScore);
r.Add(realmScore);
}
}
finally
{
transaction.Commit();
}
log($"Successfully migrated {count} scores to realm");
});
}
private void migrateSkins(OsuDbContext db)
{
// can be removed 20220530.
var existingSkins = db.SkinInfo
.Include(s => s.Files)
.ThenInclude(f => f.FileInfo)
.AsSplitQuery()
.ToList();
// previous entries in EF are removed post migration.
if (!existingSkins.Any())
return;
var userSkinChoice = config.GetBindable<string>(OsuSetting.Skin);
int.TryParse(userSkinChoice.Value, out int userSkinInt);
switch (userSkinInt)
{
case EFSkinInfo.DEFAULT_SKIN:
userSkinChoice.Value = SkinInfo.DEFAULT_SKIN.ToString();
break;
case EFSkinInfo.CLASSIC_SKIN:
userSkinChoice.Value = SkinInfo.CLASSIC_SKIN.ToString();
break;
}
realm.Run(r =>
{
using (var transaction = r.BeginWrite())
{
// only migrate data if the realm database is empty.
// note that this cannot be written as: `r.All<SkinInfo>().All(s => s.Protected)`, because realm does not support `.All()`.
if (!r.All<SkinInfo>().Any(s => !s.Protected))
{
log($"Migrating {existingSkins.Count} skins");
foreach (var skin in existingSkins)
{
var realmSkin = new SkinInfo
{
Name = skin.Name,
Creator = skin.Creator,
Hash = skin.Hash,
Protected = false,
InstantiationInfo = skin.InstantiationInfo,
};
migrateFiles(skin, r, realmSkin);
r.Add(realmSkin);
if (skin.ID == userSkinInt)
userSkinChoice.Value = realmSkin.ID.ToString();
}
}
transaction.Commit();
}
});
}
private static void migrateFiles<T>(IHasFiles<T> fileSource, Realm realm, IHasRealmFiles realmObject) where T : INamedFileInfo
{
foreach (var file in fileSource.Files)
{
var realmFile = realm.Find<RealmFile>(file.FileInfo.Hash);
if (realmFile == null)
realm.Add(realmFile = new RealmFile { Hash = file.FileInfo.Hash });
realmObject.Files.Add(new RealmNamedFileUsage(realmFile, file.Filename));
}
}
private void migrateSettings(OsuDbContext db)
{
// migrate ruleset settings. can be removed 20220315.
var existingSettings = db.DatabasedSetting.ToList();
// previous entries in EF are removed post migration.
if (!existingSettings.Any())
return;
log("Beginning settings migration to realm");
realm.Run(r =>
{
using (var transaction = r.BeginWrite())
{
// only migrate data if the realm database is empty.
if (!r.All<RealmRulesetSetting>().Any())
{
log($"Migrating {existingSettings.Count} settings");
foreach (var dkb in existingSettings)
{
if (dkb.RulesetID == null)
continue;
string? shortName = getRulesetShortNameFromLegacyID(dkb.RulesetID.Value);
if (string.IsNullOrEmpty(shortName))
continue;
r.Add(new RealmRulesetSetting
{
Key = dkb.Key,
Value = dkb.StringValue,
RulesetName = shortName,
Variant = dkb.Variant ?? 0,
});
}
}
transaction.Commit();
}
});
}
private string? getRulesetShortNameFromLegacyID(long rulesetId) =>
efContextFactory.Get().RulesetInfo.FirstOrDefault(r => r.ID == rulesetId)?.ShortName;
}
}

View File

@ -1,23 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
namespace osu.Game.Database
{
public interface IDatabaseContextFactory
{
/// <summary>
/// Get a context for read-only usage.
/// </summary>
OsuDbContext Get();
/// <summary>
/// Request a context for write usage. Can be consumed in a nested fashion (and will return the same underlying context).
/// This method may block if a write is already active on a different thread.
/// </summary>
/// <param name="withTransaction">Whether to start a transaction for this write.</param>
/// <returns>A usage containing a usable context.</returns>
DatabaseWriteUsage GetForWrite(bool withTransaction = true);
}
}

View File

@ -111,7 +111,7 @@ namespace osu.Game.Database
{
if (error is WebException webException && webException.Message == @"TooManyRequests")
{
notification.Close();
notification.Close(false);
PostNotification?.Invoke(new TooManyDownloadsNotification());
}
else

View File

@ -1,214 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using osu.Framework.Logging;
using osu.Framework.Statistics;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.IO;
using osu.Game.Rulesets;
using osu.Game.Scoring;
using osu.Game.Skinning;
using SQLitePCL;
using LogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace osu.Game.Database
{
public class OsuDbContext : DbContext
{
public DbSet<EFBeatmapInfo> EFBeatmapInfo { get; set; }
public DbSet<EFBeatmapDifficulty> BeatmapDifficulty { get; set; }
public DbSet<EFBeatmapMetadata> BeatmapMetadata { get; set; }
public DbSet<EFBeatmapSetInfo> EFBeatmapSetInfo { get; set; }
public DbSet<FileInfo> FileInfo { get; set; }
public DbSet<EFRulesetInfo> RulesetInfo { get; set; }
public DbSet<EFSkinInfo> SkinInfo { get; set; }
public DbSet<EFScoreInfo> ScoreInfo { get; set; }
// migrated to realm
public DbSet<DatabasedSetting> DatabasedSetting { get; set; }
private readonly string connectionString;
private static readonly Lazy<OsuDbLoggerFactory> logger = new Lazy<OsuDbLoggerFactory>(() => new OsuDbLoggerFactory());
private static readonly GlobalStatistic<int> contexts = GlobalStatistics.Get<int>("Database", "Contexts");
static OsuDbContext()
{
// required to initialise native SQLite libraries on some platforms.
Batteries_V2.Init();
// https://github.com/aspnet/EntityFrameworkCore/issues/9994#issuecomment-508588678
raw.sqlite3_config(2 /*SQLITE_CONFIG_MULTITHREAD*/);
}
/// <summary>
/// Create a new in-memory OsuDbContext instance.
/// </summary>
public OsuDbContext()
: this("DataSource=:memory:")
{
// required for tooling (see https://wildermuth.com/2017/07/06/Program-cs-in-ASP-NET-Core-2-0).
Migrate();
}
/// <summary>
/// Create a new OsuDbContext instance.
/// </summary>
/// <param name="connectionString">A valid SQLite connection string.</param>
public OsuDbContext(string connectionString)
{
this.connectionString = connectionString;
var connection = Database.GetDbConnection();
try
{
connection.Open();
using (var cmd = connection.CreateCommand())
{
cmd.CommandText = "PRAGMA journal_mode=WAL;";
cmd.ExecuteNonQuery();
cmd.CommandText = "PRAGMA foreign_keys=OFF;";
cmd.ExecuteNonQuery();
}
}
catch
{
connection.Close();
throw;
}
contexts.Value++;
}
~OsuDbContext()
{
// DbContext does not contain a finalizer (https://github.com/aspnet/EntityFrameworkCore/issues/8872)
// This is used to clean up previous contexts when fresh contexts are exposed via DatabaseContextFactory
Dispose();
}
private bool isDisposed;
public override void Dispose()
{
if (isDisposed) return;
isDisposed = true;
base.Dispose();
contexts.Value--;
GC.SuppressFinalize(this);
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder
// this is required for the time being due to the way we are querying in places like BeatmapStore.
// if we ever move to having consumers file their own .Includes, or get eager loading support, this could be re-enabled.
.UseSqlite(connectionString, sqliteOptions => sqliteOptions.CommandTimeout(10))
.UseLoggerFactory(logger.Value);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<EFBeatmapInfo>().HasIndex(b => b.OnlineID).IsUnique();
modelBuilder.Entity<EFBeatmapInfo>().HasIndex(b => b.MD5Hash);
modelBuilder.Entity<EFBeatmapInfo>().HasIndex(b => b.Hash);
modelBuilder.Entity<EFBeatmapSetInfo>().HasIndex(b => b.OnlineID).IsUnique();
modelBuilder.Entity<EFBeatmapSetInfo>().HasIndex(b => b.DeletePending);
modelBuilder.Entity<EFBeatmapSetInfo>().HasIndex(b => b.Hash).IsUnique();
modelBuilder.Entity<EFSkinInfo>().HasIndex(b => b.Hash).IsUnique();
modelBuilder.Entity<EFSkinInfo>().HasIndex(b => b.DeletePending);
modelBuilder.Entity<EFSkinInfo>().HasMany(s => s.Files).WithOne(f => f.SkinInfo);
modelBuilder.Entity<DatabasedSetting>().HasIndex(b => new { b.RulesetID, b.Variant });
modelBuilder.Entity<FileInfo>().HasIndex(b => b.Hash).IsUnique();
modelBuilder.Entity<FileInfo>().HasIndex(b => b.ReferenceCount);
modelBuilder.Entity<EFRulesetInfo>().HasIndex(b => b.Available);
modelBuilder.Entity<EFRulesetInfo>().HasIndex(b => b.ShortName).IsUnique();
modelBuilder.Entity<EFBeatmapInfo>().HasOne(b => b.BaseDifficulty);
modelBuilder.Entity<EFScoreInfo>().HasIndex(b => b.OnlineID).IsUnique();
}
private class OsuDbLoggerFactory : ILoggerFactory
{
#region Disposal
public void Dispose()
{
}
#endregion
public ILogger CreateLogger(string categoryName) => new OsuDbLogger();
public void AddProvider(ILoggerProvider provider)
{
// no-op. called by tooling.
}
private class OsuDbLogger : ILogger
{
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
{
if (logLevel < LogLevel.Information)
return;
Framework.Logging.LogLevel frameworkLogLevel;
switch (logLevel)
{
default:
frameworkLogLevel = Framework.Logging.LogLevel.Debug;
break;
case LogLevel.Warning:
frameworkLogLevel = Framework.Logging.LogLevel.Important;
break;
case LogLevel.Error:
case LogLevel.Critical:
frameworkLogLevel = Framework.Logging.LogLevel.Error;
break;
}
Logger.Log(formatter(state, exception), LoggingTarget.Database, frameworkLogLevel);
}
public bool IsEnabled(LogLevel logLevel)
{
#if DEBUG_DATABASE
return logLevel > LogLevel.Debug;
#else
return logLevel > LogLevel.Information;
#endif
}
public IDisposable BeginScope<TState>(TState state) => null;
}
}
public void Migrate() => Database.Migrate();
}
}

View File

@ -24,6 +24,7 @@ using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Input.Bindings;
using osu.Game.Models;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
@ -45,8 +46,6 @@ namespace osu.Game.Database
/// </summary>
public readonly string Filename;
private readonly IDatabaseContextFactory? efContextFactory;
private readonly SynchronizationContext? updateThreadSyncContext;
/// <summary>
@ -162,11 +161,9 @@ namespace osu.Game.Database
/// <param name="storage">The game storage which will be used to create the realm backing file.</param>
/// <param name="filename">The filename to use for the realm backing file. A ".realm" extension will be added automatically if not specified.</param>
/// <param name="updateThread">The game update thread, used to post realm operations into a thread-safe context.</param>
/// <param name="efContextFactory">An EF factory used only for migration purposes.</param>
public RealmAccess(Storage storage, string filename, GameThread? updateThread = null, IDatabaseContextFactory? efContextFactory = null)
public RealmAccess(Storage storage, string filename, GameThread? updateThread = null)
{
this.storage = storage;
this.efContextFactory = efContextFactory;
updateThreadSyncContext = updateThread?.SynchronizationContext ?? SynchronizationContext.Current;
@ -876,8 +873,17 @@ namespace osu.Game.Database
}
}
private string? getRulesetShortNameFromLegacyID(long rulesetId) =>
efContextFactory?.Get().RulesetInfo.FirstOrDefault(r => r.ID == rulesetId)?.ShortName;
private string? getRulesetShortNameFromLegacyID(long rulesetId)
{
try
{
return new APIBeatmap.APIRuleset { OnlineID = (int)rulesetId }.ShortName;
}
catch
{
return null;
}
}
/// <summary>
/// Create a full realm backup.