diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index 49bde7c505..59cbfcb1e3 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -543,6 +543,44 @@ namespace osu.Game.Database return writeTask; } + /// + /// Write changes to realm asynchronously, guaranteeing order of execution. + /// + /// The work to run. + public Task WriteAsync(Func action) + { + ObjectDisposedException.ThrowIf(isDisposed, this); + + // Required to ensure the write is tracked and accounted for before disposal. + // Can potentially be avoided if we have a need to do so in the future. + if (!ThreadSafety.IsUpdateThread) + throw new InvalidOperationException(@$"{nameof(WriteAsync)} must be called from the update thread."); + + // CountdownEvent will fail if already at zero. + if (!pendingAsyncWrites.TryAddCount()) + pendingAsyncWrites.Reset(1); + + // Regardless of calling Realm.GetInstance or Realm.GetInstanceAsync, there is a blocking overhead on retrieval. + // Adding a forced Task.Run resolves this. + var writeTask = Task.Run(async () => + { + T result; + total_writes_async.Value++; + + // Not attempting to use Realm.GetInstanceAsync as there's seemingly no benefit to us (for now) and it adds complexity due to locking + // concerns in getRealmInstance(). On a quick check, it looks to be more suited to cases where realm is connecting to an online sync + // server, which we don't use. May want to report upstream or revisit in the future. + using (var realm = getRealmInstance()) + // ReSharper disable once AccessToDisposedClosure (WriteAsync should be marked as [InstantHandle]). + result = await realm.WriteAsync(() => action(realm)).ConfigureAwait(false); + + pendingAsyncWrites.Signal(); + return result; + }); + + return writeTask; + } + /// /// Subscribe to a realm collection and begin watching for asynchronous changes. /// diff --git a/osu.Game/Database/RealmArchiveModelImporter.cs b/osu.Game/Database/RealmArchiveModelImporter.cs index 6f613267d6..a3cdc2dc77 100644 --- a/osu.Game/Database/RealmArchiveModelImporter.cs +++ b/osu.Game/Database/RealmArchiveModelImporter.cs @@ -205,9 +205,7 @@ namespace osu.Game.Database Directory.CreateDirectory(mountedPath); - // Detach files from the model to avoid realm contention when copying to the external location. - // This is safe as we are not modifying the model in any way. - foreach (var realmFile in model.Files.Detach()) + foreach (var realmFile in model.Files) { string sourcePath = Files.Storage.GetFullPath(realmFile.File.GetStoragePath()); string destinationPath = Path.Join(mountedPath, realmFile.Filename); diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index 538ac1dff7..d43f90c292 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -14,6 +14,7 @@ using osu.Game.Input.Bindings; using osu.Game.Models; using osu.Game.Rulesets; using osu.Game.Scoring; +using osu.Game.Skinning; using Realms; namespace osu.Game.Database @@ -177,6 +178,7 @@ namespace osu.Game.Database c.CreateMap(); c.CreateMap(); c.CreateMap(); + c.CreateMap(); } /// diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs index e1bdcaff0c..382a7b56c2 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -53,12 +53,11 @@ namespace osu.Game.Skinning /// The to update the with /// The to update /// - public override Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) + public override async Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) { - var skinInfoLive = original.ToLive(Realm); - - skinInfoLive.PerformWrite(skinInfo => + return await Realm.WriteAsync?>(r => { + var skinInfo = r.Find(original.ID)!; skinInfo.Files.Clear(); string[] filesInMountedDirectory = Directory.EnumerateFiles(task.Path, "*.*", SearchOption.AllDirectories).Select(f => Path.GetRelativePath(task.Path, f)).ToArray(); @@ -67,28 +66,28 @@ namespace osu.Game.Skinning { using var stream = File.OpenRead(Path.Combine(task.Path, file)); - modelManager.AddFile(original, stream, file); + modelManager.AddFile(skinInfo, stream, file, r); } string skinIniPath = Path.Combine(task.Path, "skin.ini"); - if (!File.Exists(skinIniPath)) - return; - - using (var stream = File.OpenRead(skinIniPath)) - using (var lineReader = new LineBufferedReader(stream)) + if (File.Exists(skinIniPath)) { - var decodedSkinIni = new LegacySkinDecoder().Decode(lineReader); + using (var stream = File.OpenRead(skinIniPath)) + using (var lineReader = new LineBufferedReader(stream)) + { + var decodedSkinIni = new LegacySkinDecoder().Decode(lineReader); - if (!string.IsNullOrEmpty(decodedSkinIni.SkinInfo.Name)) - skinInfo.Name = decodedSkinIni.SkinInfo.Name; + if (!string.IsNullOrEmpty(decodedSkinIni.SkinInfo.Name)) + skinInfo.Name = decodedSkinIni.SkinInfo.Name; - if (!string.IsNullOrEmpty(decodedSkinIni.SkinInfo.Creator)) - skinInfo.Creator = decodedSkinIni.SkinInfo.Creator; + if (!string.IsNullOrEmpty(decodedSkinIni.SkinInfo.Creator)) + skinInfo.Creator = decodedSkinIni.SkinInfo.Creator; + } } - }); - return Task.FromResult(skinInfoLive)!; + return skinInfo.ToLive(Realm); + }).ConfigureAwait(false); } protected override void Populate(SkinInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default)