mirror of
https://github.com/osukey/osukey.git
synced 2025-08-03 14:46:38 +09:00
Merge branch 'master' into fix-skin-flow
This commit is contained in:
@ -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).");
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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.
|
||||
|
Reference in New Issue
Block a user