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)