1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-20 04:39:57 +08:00
Files
osu-lazer/osu.Game/Database/RealmAccess.cs
T
Bartłomiej Dach 8cb81974eb Add initial support for filtering by user tags in song select
The way that this works is that it plugs into the online request to
retrieve the beatmap set that the client is already performing, and
stores user tag data to the local realm database.

This means that for now user tags will only populate for beatmaps that
the user has displayed on song select which is obviously subpar. I plan
to follow this change up by adding user tag state dumps to `online.db`
and using that data for initial tag population to make the majority case
(ranked beatmaps) work.

Note that several decisions were made here that are potential discussion
points:

- `RealmPopulatingOnlineLookupSource` is set up such that it can be the
  middle man / redirection point for similar flows that we need and we
  are currently missing, such as storing guest difficulty information,
  or storing the user's current best score on a beatmap (handy for rank
  achieved sorting / filtering / etc.)

- The user tags are stored in `BeatmapMetadata` which breaks the
  longstanding assumption that you can arbitrarily pull out a metadata
  instance from any of the beatmaps in a set and get essentially the
  same object back.

  I've attempted to constrain this some by not adding user tags to
  the `IBeatmapMetadataInfo` interface through which `BeatmapSetInfo`
  exposes metadata further, but I warn in advance that this is
  a temporary state of affairs and I will make it worse in the future
  when `BeatmapMetadata.Author` becomes `Authors` plural in order to
  support guest mapper display (and direct guest difficulty submission).

- The syntax for searching via user tags is chosen to mostly match web -
  it's `tag=`, with support for all of the string matching modes song
  select already has (bare word for substring, `""` quotes for phrase
  isolated by whitespace, `""!` for exact full match).
2025-07-15 11:09:40 +02:00

1512 lines
70 KiB
C#

// 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.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Development;
using osu.Framework.Extensions;
using osu.Framework.Input.Bindings;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Statistics;
using osu.Framework.Threading;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Configuration;
using osu.Game.Extensions;
using osu.Game.Input;
using osu.Game.Input.Bindings;
using osu.Game.Models;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
using osu.Game.Scoring.Legacy;
using osu.Game.Skinning;
using osu.Game.Utils;
using osuTK.Input;
using Realms;
using Realms.Exceptions;
namespace osu.Game.Database
{
/// <summary>
/// A factory which provides safe access to the realm storage backend.
/// </summary>
public class RealmAccess : IDisposable
{
private readonly Storage storage;
/// <summary>
/// The filename of this realm.
/// </summary>
public readonly string Filename;
private readonly SynchronizationContext? updateThreadSyncContext;
/// <summary>
/// Version history:
/// 6 ~2021-10-18 First tracked version.
/// 7 2021-10-18 Changed OnlineID fields to non-nullable to add indexing support.
/// 8 2021-10-29 Rebind scroll adjust keys to not have control modifier.
/// 9 2021-11-04 Converted BeatmapMetadata.Author from string to RealmUser.
/// 10 2021-11-22 Use ShortName instead of RulesetID for ruleset settings.
/// 11 2021-11-22 Use ShortName instead of RulesetID for ruleset key bindings.
/// 12 2021-11-24 Add Status to RealmBeatmapSet.
/// 13 2022-01-13 Final migration of beatmaps and scores to realm (multiple new storage fields).
/// 14 2022-03-01 Added BeatmapUserSettings to BeatmapInfo.
/// 15 2022-07-13 Added LastPlayed to BeatmapInfo.
/// 16 2022-07-15 Removed HasReplay from ScoreInfo.
/// 17 2022-07-16 Added CountryCode to RealmUser.
/// 18 2022-07-19 Added OnlineMD5Hash and LastOnlineUpdate to BeatmapInfo.
/// 19 2022-07-19 Added DateSubmitted and DateRanked to BeatmapSetInfo.
/// 20 2022-07-21 Added LastAppliedDifficultyVersion to RulesetInfo, changed default value of BeatmapInfo.StarRating to -1.
/// 21 2022-07-27 Migrate collections to realm (BeatmapCollection).
/// 22 2022-07-31 Added ModPreset.
/// 23 2022-08-01 Added LastLocalUpdate to BeatmapInfo.
/// 24 2022-08-22 Added MaximumStatistics to ScoreInfo.
/// 25 2022-09-18 Remove skins to add with new naming.
/// 26 2023-02-05 Added BeatmapHash to ScoreInfo.
/// 27 2023-06-06 Added EditorTimestamp to BeatmapInfo.
/// 28 2023-06-08 Added IsLegacyScore to ScoreInfo, parsed from replay files.
/// 29 2023-06-12 Run migration of old lazer scores to be best-effort in the new scoring number space. No actual realm changes.
/// 30 2023-06-16 Run migration of old lazer scores again. This time with more correct rounding considerations.
/// 31 2023-06-26 Add Version and LegacyTotalScore to ScoreInfo, set Version to 30000002 and copy TotalScore into LegacyTotalScore for legacy scores.
/// 32 2023-07-09 Populate legacy scores with the ScoreV2 mod (and restore TotalScore to the legacy total for such scores) using replay files.
/// 33 2023-08-16 Reset default chat toggle key binding to avoid conflict with newly added leaderboard toggle key binding.
/// 34 2023-08-21 Add BackgroundReprocessingFailed flag to ScoreInfo to track upgrade failures.
/// 35 2023-10-16 Clear key combinations of keybindings that are assigned to more than one action in a given settings section.
/// 36 2023-10-26 Add LegacyOnlineID to ScoreInfo. Move osu_scores_*_high IDs stored in OnlineID to LegacyOnlineID. Reset anomalous OnlineIDs.
/// 38 2023-12-10 Add EndTimeObjectCount and TotalObjectCount to BeatmapInfo.
/// 39 2023-12-19 Migrate any EndTimeObjectCount and TotalObjectCount values of 0 to -1 to better identify non-calculated values.
/// 40 2023-12-21 Add ScoreInfo.Version to keep track of which build scores were set on.
/// 41 2024-04-17 Add ScoreInfo.TotalScoreWithoutMods for future mod multiplier rebalances.
/// 42 2024-08-07 Update mania key bindings to reflect changes to ManiaAction
/// 43 2024-10-14 Reset keybind for toggling FPS display to avoid conflict with "convert to stream" in the editor, if not already changed by user.
/// 44 2024-11-22 Removed several properties from BeatmapInfo which did not need to be persisted to realm.
/// 45 2024-12-23 Change beat snap divisor adjust defaults to be Ctrl+Scroll instead of Ctrl+Shift+Scroll, if not already changed by user.
/// 46 2024-12-26 Change beat snap divisor bindings to match stable directionality ¯\_(ツ)_/¯.
/// 47 2025-01-21 Remove right mouse button binding for absolute scroll. Never use mouse buttons (or scroll) for global actions.
/// 48 2025-03-19 Clear online status for all qualified beatmaps (some were stuck in a qualified state due to local caching issues).
/// 49 2025-06-10 Reset the LegacyOnlineID to -1 for all scores that have it set to 0 (which is semantically the same) for consistency of handling with OnlineID.
/// 50 2025-07-11 Add UserTags to BeatmapMetadata.
/// </summary>
private const int schema_version = 50;
/// <summary>
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
/// </summary>
private readonly SemaphoreSlim realmRetrievalLock = new SemaphoreSlim(1);
/// <summary>
/// <c>true</c> when the current thread has already entered the <see cref="realmRetrievalLock"/>.
/// </summary>
private readonly ThreadLocal<bool> currentThreadHasRealmRetrievalLock = new ThreadLocal<bool>();
/// <summary>
/// Holds a map of functions registered via <see cref="RegisterCustomSubscription"/> and <see cref="RegisterForNotifications{T}"/> and a coinciding action which when triggered,
/// will unregister the subscription from realm.
///
/// Put another way, the key is an action which registers the subscription with realm. The returned <see cref="IDisposable"/> from the action is stored as the value and only
/// used internally.
///
/// Entries in this dictionary are only removed when a consumer signals that the subscription should be permanently ceased (via their own <see cref="IDisposable"/>).
/// </summary>
private readonly Dictionary<Func<Realm, IDisposable?>, IDisposable?> customSubscriptionsResetMap = new Dictionary<Func<Realm, IDisposable?>, IDisposable?>();
/// <summary>
/// Holds a map of functions registered via <see cref="RegisterForNotifications{T}"/> and a coinciding action which when triggered,
/// fires a change set event with an empty collection. This is used to inform subscribers when the main realm instance gets recycled, and ensure they don't use invalidated
/// managed realm objects from a previous firing.
/// </summary>
private readonly Dictionary<Func<Realm, IDisposable?>, Action> notificationsResetMap = new Dictionary<Func<Realm, IDisposable?>, Action>();
private static readonly GlobalStatistic<int> realm_instances_created = GlobalStatistics.Get<int>(@"Realm", @"Instances (Created)");
private static readonly GlobalStatistic<int> total_subscriptions = GlobalStatistics.Get<int>(@"Realm", @"Subscriptions");
private static readonly GlobalStatistic<int> total_reads_update = GlobalStatistics.Get<int>(@"Realm", @"Reads (Update)");
private static readonly GlobalStatistic<int> total_reads_async = GlobalStatistics.Get<int>(@"Realm", @"Reads (Async)");
private static readonly GlobalStatistic<int> total_writes_update = GlobalStatistics.Get<int>(@"Realm", @"Writes (Update)");
private static readonly GlobalStatistic<int> total_writes_async = GlobalStatistics.Get<int>(@"Realm", @"Writes (Async)");
private Realm? updateRealm;
/// <summary>
/// Tracks whether a realm was ever fetched from this instance.
/// After a fetch occurs, blocking operations will be guaranteed to restore any subscriptions.
/// </summary>
private bool hasInitialisedOnce;
private bool isSendingNotificationResetEvents;
public Realm Realm => ensureUpdateRealm();
private const string realm_extension = @".realm";
private Realm ensureUpdateRealm()
{
if (isSendingNotificationResetEvents)
throw new InvalidOperationException("Cannot retrieve a realm context from a notification callback during a blocking operation.");
if (!ThreadSafety.IsUpdateThread)
throw new InvalidOperationException(@$"Use {nameof(getRealmInstance)} when performing realm operations from a non-update thread");
if (updateRealm == null)
{
updateRealm = getRealmInstance();
hasInitialisedOnce = true;
Logger.Log(@$"Opened realm ""{updateRealm.Config.DatabasePath}"" at version {updateRealm.Config.SchemaVersion}");
// Resubscribe any subscriptions
foreach (var action in customSubscriptionsResetMap.Keys.ToArray())
registerSubscription(action);
}
Debug.Assert(updateRealm != null);
return updateRealm;
}
internal static bool CurrentThreadSubscriptionsAllowed => current_thread_subscriptions_allowed.Value;
private static readonly ThreadLocal<bool> current_thread_subscriptions_allowed = new ThreadLocal<bool>();
/// <summary>
/// Construct a new instance.
/// </summary>
/// <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>
public RealmAccess(Storage storage, string filename, GameThread? updateThread = null)
{
this.storage = storage;
updateThreadSyncContext = updateThread?.SynchronizationContext ?? SynchronizationContext.Current;
Filename = filename;
if (!Filename.EndsWith(realm_extension, StringComparison.Ordinal))
Filename += realm_extension;
#if DEBUG
if (!DebugUtils.IsNUnitRunning)
applyFilenameSchemaSuffix(ref Filename);
#endif
// `prepareFirstRealmAccess()` triggers the first `getRealmInstance` call, which will implicitly run realm migrations and bring the schema up-to-date.
using (var realm = prepareFirstRealmAccess())
cleanupPendingDeletions(realm);
}
/// <summary>
/// Some developers may be annoyed if a newer version migration (ie. caused by testing a pull request)
/// cause their test database to be unusable with previous versions.
/// To get around this, store development databases against their realm version.
/// Note that this means changes made on newer realm versions will disappear.
/// </summary>
private void applyFilenameSchemaSuffix(ref string filename)
{
string originalFilename = filename;
filename = getVersionedFilename(schema_version);
// First check if the current realm version already exists...
if (storage.Exists(filename))
return;
// Check for a previous version we can use as a base database to migrate from...
for (int i = schema_version - 1; i >= 0; i--)
{
string previousFilename = getVersionedFilename(i);
if (storage.Exists(previousFilename))
{
copyPreviousVersion(previousFilename, filename);
return;
}
}
// Finally, check for a non-versioned file exists (aka before this method was added)...
if (storage.Exists(originalFilename))
copyPreviousVersion(originalFilename, filename);
void copyPreviousVersion(string previousFilename, string newFilename)
{
using (var previous = storage.GetStream(previousFilename))
using (var current = storage.CreateFileSafely(newFilename))
{
Logger.Log(@$"Copying previous realm database {previousFilename} to {newFilename} for migration to schema version {schema_version}");
previous.CopyTo(current);
}
}
string getVersionedFilename(int version) => originalFilename.Replace(realm_extension, $"_{version}{realm_extension}");
}
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 {OsuGameBase.CLIENT_DATABASE_FILENAME} 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 (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.CreateFileSafely(Filename))
inputStream.CopyTo(outputStream);
storage.Delete(recoveryFilename);
Logger.Log(@"Recovery complete!", LoggingTarget.Database);
}
private Realm prepareFirstRealmAccess()
{
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
{
// Some platforms' realm implementation (including windows) don't update modified time on open.
// Let's do this explicitly as some users may depend on it roughly aligning to usage expectations.
string fullPath = storage.GetFullPath(Filename);
var fi = new FileInfo(fullPath);
if (fi.Exists)
fi.LastWriteTime = DateTime.Now;
}
catch { }
try
{
return getRealmInstance();
}
catch (Exception e)
{
// 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.");
// If a newer version database already exists, don't create another backup. We can presume that the first backup is the one we care about.
if (!storage.Exists(newerVersionFilename))
createBackup(newerVersionFilename);
}
else
{
// This error can occur due to file handles still being open by a previous instance.
// If this is the case, rather than assuming the realm file is corrupt, block game startup.
if (e.Message.StartsWith("SetEndOfFile() failed", StringComparison.Ordinal))
{
// This will throw if the realm file is not available for write access after 5 seconds.
FileUtils.AttemptOperation(() =>
{
if (storage.Exists(Filename))
{
using (var _ = storage.GetStream(Filename, FileAccess.ReadWrite))
{
}
}
}, 20);
// If the above eventually succeeds, try and continue startup as per normal.
// This may throw again but let's allow it to, and block startup.
return getRealmInstance();
}
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);
return getRealmInstance();
}
}
private void cleanupPendingDeletions(Realm realm)
{
try
{
using (var transaction = realm.BeginWrite())
{
var pendingDeleteScores = realm.All<ScoreInfo>().Where(s => s.DeletePending);
foreach (var score in pendingDeleteScores)
realm.Remove(score);
var pendingDeleteSets = realm.All<BeatmapSetInfo>().Where(s => s.DeletePending);
foreach (var beatmapSet in pendingDeleteSets)
{
foreach (var beatmap in beatmapSet.Beatmaps)
{
realm.Remove(beatmap.Metadata);
realm.Remove(beatmap);
}
realm.Remove(beatmapSet);
}
var pendingDeleteSkins = realm.All<SkinInfo>().Where(s => s.DeletePending);
foreach (var s in pendingDeleteSkins)
realm.Remove(s);
var pendingDeletePresets = realm.All<ModPreset>().Where(s => s.DeletePending);
foreach (var s in pendingDeletePresets)
realm.Remove(s);
transaction.Commit();
}
// clean up files after dropping any pending deletions.
// in the future we may want to only do this when the game is idle, rather than on every startup.
new RealmFileStore(this, storage).Cleanup();
}
catch (Exception e)
{
Logger.Error(e, "Failed to clean up unused files. This is not critical but please report if it happens regularly.");
}
}
/// <summary>
/// Compact this realm.
/// </summary>
/// <returns></returns>
public bool Compact() => Realm.Compact(getConfiguration());
/// <summary>
/// Run work on realm with a return value.
/// </summary>
/// <param name="action">The work to run.</param>
/// <typeparam name="T">The return type.</typeparam>
public T Run<T>(Func<Realm, T> action)
{
if (ThreadSafety.IsUpdateThread)
{
total_reads_update.Value++;
return action(Realm);
}
total_reads_async.Value++;
using (var realm = getRealmInstance())
return action(realm);
}
/// <summary>
/// Run work on realm.
/// </summary>
/// <param name="action">The work to run.</param>
public void Run(Action<Realm> action)
{
if (ThreadSafety.IsUpdateThread)
{
total_reads_update.Value++;
action(Realm);
}
else
{
total_reads_async.Value++;
using (var realm = getRealmInstance())
action(realm);
}
}
/// <summary>
/// Write changes to realm.
/// </summary>
/// <param name="action">The work to run.</param>
public T Write<T>(Func<Realm, T> action)
{
if (ThreadSafety.IsUpdateThread)
{
total_writes_update.Value++;
return Realm.Write(action);
}
else
{
total_writes_async.Value++;
using (var realm = getRealmInstance())
return realm.Write(action);
}
}
/// <summary>
/// Write changes to realm.
/// </summary>
/// <param name="action">The work to run.</param>
public void Write(Action<Realm> action)
{
if (ThreadSafety.IsUpdateThread)
{
total_writes_update.Value++;
Realm.Write(action);
}
else
{
total_writes_async.Value++;
using (var realm = getRealmInstance())
realm.Write(action);
}
}
private readonly CountdownEvent pendingAsyncWrites = new CountdownEvent(0);
/// <summary>
/// Write changes to realm asynchronously, guaranteeing order of execution.
/// </summary>
/// <param name="action">The work to run.</param>
public Task WriteAsync(Action<Realm> 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 () =>
{
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]).
await realm.WriteAsync(() => action(realm)).ConfigureAwait(false);
pendingAsyncWrites.Signal();
});
return writeTask;
}
/// <summary>
/// Write changes to realm asynchronously, guaranteeing order of execution.
/// </summary>
/// <param name="action">The work to run.</param>
public Task<T> WriteAsync<T>(Func<Realm, T> 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;
}
/// <summary>
/// Subscribe to a realm collection and begin watching for asynchronous changes.
/// </summary>
/// <remarks>
/// This adds osu! specific thread and managed state safety checks on top of <see cref="IRealmCollection{T}.SubscribeForNotifications"/>.
///
/// In addition to the documented realm behaviour, we have the additional requirement of handling subscriptions over potential realm instance recycle.
/// When this happens, callback events will be automatically fired:
/// - On recycle start, a callback with an empty collection and <c>null</c> <see cref="ChangeSet"/> will be invoked.
/// - On recycle end, a standard initial realm callback will arrive, with <c>null</c> <see cref="ChangeSet"/> and an up-to-date collection.
/// </remarks>
/// <param name="query">The <see cref="IQueryable{T}"/> to observe for changes.</param>
/// <typeparam name="T">Type of the elements in the list.</typeparam>
/// <param name="callback">The callback to be invoked with the updated <see cref="IRealmCollection{T}"/>.</param>
/// <returns>
/// A subscription token. It must be kept alive for as long as you want to receive change notifications.
/// To stop receiving notifications, call <see cref="IDisposable.Dispose"/>.
/// </returns>
/// <seealso cref="IRealmCollection{T}.SubscribeForNotifications"/>
public IDisposable RegisterForNotifications<T>(Func<Realm, IQueryable<T>> query, NotificationCallbackDelegate<T> callback)
where T : RealmObjectBase
{
Func<Realm, IDisposable?> action = realm => query(realm).QueryAsyncWithNotifications(callback);
lock (notificationsResetMap)
{
// Store an action which is used when blocking to ensure consumers don't use results of a stale changeset firing.
notificationsResetMap.Add(action, () => callback(new RealmResetEmptySet<T>(), null));
}
return RegisterCustomSubscription(action);
}
/// <summary>
/// Subscribe to the property of a realm object to watch for changes.
/// </summary>
/// <remarks>
/// On subscribing, unless the <paramref name="modelAccessor"/> does not match an object, an initial invocation of <paramref name="onChanged"/> will occur immediately.
/// Further invocations will occur when the value changes, but may also fire on a realm recycle with no actual value change.
/// </remarks>
/// <param name="modelAccessor">A function to retrieve the relevant model from realm.</param>
/// <param name="propertyLookup">A function to traverse to the relevant property from the model.</param>
/// <param name="onChanged">A function to be invoked when a change of value occurs.</param>
/// <typeparam name="TModel">The type of the model.</typeparam>
/// <typeparam name="TProperty">The type of the property to be watched.</typeparam>
/// <returns>
/// A subscription token. It must be kept alive for as long as you want to receive change notifications.
/// To stop receiving notifications, call <see cref="IDisposable.Dispose"/>.
/// </returns>
public IDisposable SubscribeToPropertyChanged<TModel, TProperty>(Func<Realm, TModel?> modelAccessor, Expression<Func<TModel, TProperty>> propertyLookup, Action<TProperty> onChanged)
where TModel : RealmObjectBase
{
return RegisterCustomSubscription(_ =>
{
string propertyName = getMemberName(propertyLookup);
var model = Run(modelAccessor);
var propLookupCompiled = propertyLookup.Compile();
if (model == null)
return null;
model.PropertyChanged += onPropertyChanged;
// Update initial value immediately.
onChanged(propLookupCompiled(model));
return new InvokeOnDisposal(() => model.PropertyChanged -= onPropertyChanged);
void onPropertyChanged(object? sender, PropertyChangedEventArgs args)
{
if (args.PropertyName == propertyName)
onChanged(propLookupCompiled(model));
}
});
static string getMemberName(Expression<Func<TModel, TProperty>> expression)
{
if (!(expression is LambdaExpression lambda))
throw new ArgumentException("Outermost expression must be a lambda expression", nameof(expression));
if (!(lambda.Body is MemberExpression memberExpression))
throw new ArgumentException("Lambda body must be a member access expression", nameof(expression));
// TODO: nested access can be supported, with more iteration here
// (need to iteratively soft-cast `memberExpression.Expression` into `MemberExpression`s until `lambda.Parameters[0]` is hit)
if (memberExpression.Expression != lambda.Parameters[0])
throw new ArgumentException("Nested access expressions are not supported", nameof(expression));
return memberExpression.Member.Name;
}
}
/// <summary>
/// Run work on realm that will be run every time the update thread realm instance gets recycled.
/// </summary>
/// <param name="action">The work to run. Return value should be an <see cref="IDisposable"/> from QueryAsyncWithNotifications, or an <see cref="InvokeOnDisposal"/> to clean up any bindings.</param>
/// <returns>An <see cref="IDisposable"/> which should be disposed to unsubscribe any inner subscription.</returns>
public IDisposable RegisterCustomSubscription(Func<Realm, IDisposable?> action)
{
if (updateThreadSyncContext == null)
throw new InvalidOperationException("Attempted to register a realm subscription before update thread registration.");
total_subscriptions.Value++;
if (ThreadSafety.IsUpdateThread)
updateThreadSyncContext.Send(_ => registerSubscription(action), null);
else
updateThreadSyncContext.Post(_ => registerSubscription(action), null);
// This token is returned to the consumer.
// When disposed, it will cause the registration to be permanently ceased (unsubscribed with realm and unregistered by this class).
return new InvokeOnDisposal(() =>
{
if (ThreadSafety.IsUpdateThread)
updateThreadSyncContext.Send(_ => unsubscribe(), null);
else
updateThreadSyncContext.Post(_ => unsubscribe(), null);
void unsubscribe()
{
if (customSubscriptionsResetMap.TryGetValue(action, out var unsubscriptionAction))
{
unsubscriptionAction?.Dispose();
customSubscriptionsResetMap.Remove(action);
lock (notificationsResetMap)
{
notificationsResetMap.Remove(action);
}
total_subscriptions.Value--;
}
}
});
}
private void registerSubscription(Func<Realm, IDisposable?> action)
{
Debug.Assert(ThreadSafety.IsUpdateThread);
// Retrieve realm instance outside of flag update to ensure that the instance is retrieved,
// as attempting to access it inside the subscription if it's not constructed would lead to
// cyclic invocations of the subscription callback.
var realm = Realm;
Debug.Assert(!customSubscriptionsResetMap.TryGetValue(action, out var found) || found == null);
current_thread_subscriptions_allowed.Value = true;
customSubscriptionsResetMap[action] = action(realm);
current_thread_subscriptions_allowed.Value = false;
}
private Realm getRealmInstance()
{
ObjectDisposedException.ThrowIf(isDisposed, this);
bool tookSemaphoreLock = false;
try
{
// Ensure that the thread that currently has the `realmRetrievalLock` can retrieve nested contexts and not deadlock on itself.
if (!currentThreadHasRealmRetrievalLock.Value)
{
realmRetrievalLock.Wait();
currentThreadHasRealmRetrievalLock.Value = true;
tookSemaphoreLock = true;
}
else
{
// the semaphore is used to handle blocking of all realm retrieval during certain periods.
// once the semaphore has been taken by this code section, it is safe to retrieve further realm instances on the same thread.
// this can happen if a realm subscription is active and triggers a callback which has user code that calls `Run`.
}
realm_instances_created.Value++;
return Realm.GetInstance(getConfiguration());
}
finally
{
if (tookSemaphoreLock)
{
realmRetrievalLock.Release();
currentThreadHasRealmRetrievalLock.Value = false;
}
}
}
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).
string tempPathLocation = Path.Combine(Path.GetTempPath(), @"lazer");
if (!Directory.Exists(tempPathLocation))
Directory.CreateDirectory(tempPathLocation);
return new RealmConfiguration(storage.GetFullPath(filename ?? Filename, true))
{
SchemaVersion = schema_version,
MigrationCallback = onMigration,
FallbackPipePath = tempPathLocation,
};
}
private void onMigration(Migration migration, ulong lastSchemaVersion)
{
for (ulong i = lastSchemaVersion + 1; i <= schema_version; i++)
applyMigrationsForVersion(migration, i);
}
private void applyMigrationsForVersion(Migration migration, ulong targetVersion)
{
Logger.Log($"Running realm migration to version {targetVersion}...");
Stopwatch stopwatch = new Stopwatch();
var files = new RealmFileStore(this, storage);
stopwatch.Start();
switch (targetVersion)
{
case 7:
convertOnlineIDs<BeatmapInfo>();
convertOnlineIDs<BeatmapSetInfo>();
convertOnlineIDs<RulesetInfo>();
void convertOnlineIDs<T>() where T : RealmObject
{
string className = getMappedOrOriginalName(typeof(T));
// version was not bumped when the beatmap/ruleset models were added
// therefore we must manually check for their presence to avoid throwing on the `DynamicApi` calls.
if (!migration.OldRealm.Schema.TryFindObjectSchema(className, out _))
return;
var oldItems = migration.OldRealm.DynamicApi.All(className);
var newItems = migration.NewRealm.DynamicApi.All(className);
int itemCount = newItems.Count();
for (int i = 0; i < itemCount; i++)
{
dynamic oldItem = oldItems.ElementAt(i);
dynamic newItem = newItems.ElementAt(i);
long? nullableOnlineID = oldItem.OnlineID;
newItem.OnlineID = (int)(nullableOnlineID ?? -1);
}
}
break;
case 8:
{
// Ctrl -/+ now adjusts UI scale so let's clear any bindings which overlap these combinations.
// New defaults will be populated by the key store afterwards.
var keyBindings = migration.NewRealm.All<RealmKeyBinding>();
var increaseSpeedBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.IncreaseScrollSpeed);
if (increaseSpeedBinding != null && increaseSpeedBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Control, InputKey.Plus }))
migration.NewRealm.Remove(increaseSpeedBinding);
var decreaseSpeedBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.DecreaseScrollSpeed);
if (decreaseSpeedBinding != null && decreaseSpeedBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Control, InputKey.Minus }))
migration.NewRealm.Remove(decreaseSpeedBinding);
break;
}
case 9:
// Pretty pointless to do this as beatmaps aren't really loaded via realm yet, but oh well.
string metadataClassName = getMappedOrOriginalName(typeof(BeatmapMetadata));
// May be coming from a version before `RealmBeatmapMetadata` existed.
if (!migration.OldRealm.Schema.TryFindObjectSchema(metadataClassName, out _))
return;
var oldMetadata = migration.OldRealm.DynamicApi.All(metadataClassName);
var newMetadata = migration.NewRealm.All<BeatmapMetadata>();
int metadataCount = newMetadata.Count();
for (int i = 0; i < metadataCount; i++)
{
dynamic oldItem = oldMetadata.ElementAt(i);
var newItem = newMetadata.ElementAt(i);
string username = oldItem.Author;
newItem.Author = new RealmUser
{
Username = username
};
}
break;
case 10:
string rulesetSettingClassName = getMappedOrOriginalName(typeof(RealmRulesetSetting));
if (!migration.OldRealm.Schema.TryFindObjectSchema(rulesetSettingClassName, out _))
return;
var oldSettings = migration.OldRealm.DynamicApi.All(rulesetSettingClassName);
var newSettings = migration.NewRealm.All<RealmRulesetSetting>().ToList();
for (int i = 0; i < newSettings.Count; i++)
{
dynamic oldItem = oldSettings.ElementAt(i);
var newItem = newSettings.ElementAt(i);
long rulesetId = oldItem.RulesetID;
string? rulesetName = getRulesetShortNameFromLegacyID(rulesetId);
if (string.IsNullOrEmpty(rulesetName))
migration.NewRealm.Remove(newItem);
else
newItem.RulesetName = rulesetName;
}
break;
case 11:
{
string keyBindingClassName = getMappedOrOriginalName(typeof(RealmKeyBinding));
if (!migration.OldRealm.Schema.TryFindObjectSchema(keyBindingClassName, out _))
return;
var oldKeyBindings = migration.OldRealm.DynamicApi.All(keyBindingClassName);
var newKeyBindings = migration.NewRealm.All<RealmKeyBinding>().ToList();
for (int i = 0; i < newKeyBindings.Count; i++)
{
dynamic oldItem = oldKeyBindings.ElementAt(i);
var newItem = newKeyBindings.ElementAt(i);
if (oldItem.RulesetID == null)
continue;
long rulesetId = oldItem.RulesetID;
string? rulesetName = getRulesetShortNameFromLegacyID(rulesetId);
if (string.IsNullOrEmpty(rulesetName))
migration.NewRealm.Remove(newItem);
else
newItem.RulesetName = rulesetName;
}
break;
}
case 14:
foreach (var beatmap in migration.NewRealm.All<BeatmapInfo>())
beatmap.UserSettings = new BeatmapUserSettings();
break;
case 20:
// As we now have versioned difficulty calculations, let's reset
// all star ratings and have `BackgroundBeatmapProcessor` recalculate them.
foreach (var beatmap in migration.NewRealm.All<BeatmapInfo>())
beatmap.StarRating = -1;
break;
case 21:
// Migrate collections from external file to inside realm.
// We use the "legacy" importer because that is how things were actually being saved out until now.
var legacyCollectionImporter = new LegacyCollectionImporter(this);
if (legacyCollectionImporter.GetAvailableCount(storage).GetResultSafely() > 0)
{
legacyCollectionImporter.ImportFromStorage(storage).ContinueWith(_ => storage.Move("collection.db", "collection.db.migrated"));
}
break;
case 25:
// Remove the default skins so they can be added back by SkinManager with updated naming.
migration.NewRealm.RemoveRange(migration.NewRealm.All<SkinInfo>().Where(s => s.Protected));
break;
case 26:
{
// Add ScoreInfo.BeatmapHash property to ensure scores correspond to the correct version of beatmap.
var scores = migration.NewRealm.All<ScoreInfo>();
foreach (var score in scores)
score.BeatmapHash = score.BeatmapInfo?.Hash ?? string.Empty;
break;
}
case 28:
{
var scores = migration.NewRealm.All<ScoreInfo>();
foreach (var score in scores)
{
score.PopulateFromReplay(files, sr =>
{
sr.ReadByte(); // Ruleset.
int version = sr.ReadInt32();
if (version < LegacyScoreEncoder.FIRST_LAZER_VERSION)
score.IsLegacyScore = true;
});
}
break;
}
case 29:
case 30:
{
var scores = migration.NewRealm
.All<ScoreInfo>()
.Where(s => !s.IsLegacyScore);
foreach (var score in scores)
{
try
{
if (StandardisedScoreMigrationTools.ShouldMigrateToNewStandardised(score))
{
try
{
long calculatedNew = StandardisedScoreMigrationTools.GetNewStandardised(score);
score.TotalScore = calculatedNew;
}
catch
{
}
}
}
catch { }
}
break;
}
case 31:
{
foreach (var score in migration.NewRealm.All<ScoreInfo>())
{
if (score.IsLegacyScore && score.Ruleset.IsLegacyRuleset())
{
// Scores with this version will trigger the score upgrade process in BackgroundBeatmapProcessor.
score.TotalScoreVersion = 30000002;
// Transfer known legacy scores to a permanent storage field for preservation.
score.LegacyTotalScore = score.TotalScore;
}
else
score.TotalScoreVersion = LegacyScoreEncoder.LATEST_VERSION;
}
break;
}
case 32:
{
foreach (var score in migration.NewRealm.All<ScoreInfo>())
{
if (!score.IsLegacyScore || !score.Ruleset.IsLegacyRuleset())
continue;
score.PopulateFromReplay(files, sr =>
{
sr.ReadByte(); // Ruleset.
sr.ReadInt32(); // Version.
sr.ReadString(); // Beatmap hash.
sr.ReadString(); // Username.
sr.ReadString(); // MD5Hash.
sr.ReadUInt16(); // Count300.
sr.ReadUInt16(); // Count100.
sr.ReadUInt16(); // Count50.
sr.ReadUInt16(); // CountGeki.
sr.ReadUInt16(); // CountKatu.
sr.ReadUInt16(); // CountMiss.
// we should have this in LegacyTotalScore already, but if we're reading through this anyways...
int totalScore = sr.ReadInt32();
sr.ReadUInt16(); // Max combo.
sr.ReadBoolean(); // Perfect.
var legacyMods = (LegacyMods)sr.ReadInt32();
if (!legacyMods.HasFlag(LegacyMods.ScoreV2) || score.APIMods.Any(mod => mod.Acronym == @"SV2"))
return;
score.APIMods = score.APIMods.Append(new APIMod(new ModScoreV2())).ToArray();
score.LegacyTotalScore = score.TotalScore = totalScore;
});
}
break;
}
case 33:
{
// Clear default bindings for the chat focus toggle,
// as they would conflict with the newly-added leaderboard toggle.
var keyBindings = migration.NewRealm.All<RealmKeyBinding>();
var toggleChatBind = keyBindings.FirstOrDefault(bind => bind.ActionInt == (int)GlobalAction.ToggleChatFocus);
if (toggleChatBind != null && toggleChatBind.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Tab }))
migration.NewRealm.Remove(toggleChatBind);
break;
}
case 35:
{
// catch used `Shift` twice as a default key combination for dash, which generally was bothersome and causes issues elsewhere.
// the duplicate binding logic below had to account for it, it could also break keybinding conflict resolution on revert-to-default.
// as such, detect this situation and fix it before proceeding further.
var catchDashBindings = migration.NewRealm.All<RealmKeyBinding>()
.Where(kb => kb.RulesetName == @"fruits" && kb.ActionInt == 2)
.ToList();
if (catchDashBindings.All(kb => kb.KeyCombination.Equals(new KeyCombination(InputKey.Shift))))
{
Debug.Assert(catchDashBindings.Count == 2);
catchDashBindings.Last().KeyCombination = KeyCombination.FromMouseButton(MouseButton.Left);
}
// with the catch case dealt with, de-duplicate the remaining bindings.
int countCleared = 0;
var globalBindings = migration.NewRealm.All<RealmKeyBinding>().Where(kb => kb.RulesetName == null).ToList();
foreach (var category in Enum.GetValues<GlobalActionCategory>())
{
var categoryActions = GlobalActionContainer.GetGlobalActionsFor(category).Cast<int>().ToHashSet();
var categoryBindings = globalBindings.Where(kb => categoryActions.Contains(kb.ActionInt));
countCleared += RealmKeyBindingStore.ClearDuplicateBindings(categoryBindings);
}
var rulesetBindings = migration.NewRealm.All<RealmKeyBinding>().Where(kb => kb.RulesetName != null).ToList();
foreach (var variantGroup in rulesetBindings.GroupBy(kb => (kb.RulesetName, kb.Variant)))
countCleared += RealmKeyBindingStore.ClearDuplicateBindings(variantGroup);
if (countCleared > 0)
{
Logger.Log($"{countCleared} of your keybinding(s) have been cleared due to being bound to multiple actions. "
+ "Please choose new unique ones in the settings panel.", level: LogLevel.Important);
}
break;
}
case 36:
{
foreach (var score in migration.NewRealm.All<ScoreInfo>())
{
if (score.OnlineID > 0)
{
score.LegacyOnlineID = score.OnlineID;
score.OnlineID = -1;
}
else
{
score.LegacyOnlineID = score.OnlineID = -1;
}
}
break;
}
case 39:
foreach (var b in migration.NewRealm.All<BeatmapInfo>())
{
// Either actually no objects, or processing ran and failed.
// Reset to -1 so the next time they become zero we know that processing was attempted.
if (b.TotalObjectCount == 0 && b.EndTimeObjectCount == 0)
{
b.TotalObjectCount = -1;
b.EndTimeObjectCount = -1;
}
}
break;
case 41:
foreach (var score in migration.NewRealm.All<ScoreInfo>())
{
try
{
// this can fail e.g. if a user has a score set on a ruleset that can no longer be loaded.
LegacyScoreDecoder.PopulateTotalScoreWithoutMods(score);
}
catch (Exception ex)
{
Logger.Log($@"Failed to populate total score without mods for score {score.ID}: {ex}", LoggingTarget.Database);
}
}
break;
case 42:
for (int columns = 1; columns <= 10; columns++)
{
remapKeyBindingsForVariant(columns, false);
remapKeyBindingsForVariant(columns, true);
}
// Replace existing key bindings with new ones reflecting changes to ManiaAction:
// - "Special#" actions are removed and "Key#" actions are inserted in their place.
// - All actions are renumbered to remove the old offsets.
void remapKeyBindingsForVariant(int columns, bool dual)
{
// https://github.com/ppy/osu/blob/8773c2f7ebc226942d6124eb95c07a83934272ea/osu.Game.Rulesets.Mania/ManiaRuleset.cs#L327-L336
int variant = dual ? 1000 + (columns * 2) : columns;
var oldKeyBindingsQuery = migration.NewRealm
.All<RealmKeyBinding>()
.Where(kb => kb.RulesetName == @"mania" && kb.Variant == variant);
var oldKeyBindings = oldKeyBindingsQuery.Detach();
migration.NewRealm.RemoveRange(oldKeyBindingsQuery);
// https://github.com/ppy/osu/blob/8773c2f7ebc226942d6124eb95c07a83934272ea/osu.Game.Rulesets.Mania/ManiaInputManager.cs#L22-L31
int oldNormalAction = 10; // Old Key1 offset
int oldSpecialAction = 1; // Old Special1 offset
for (int column = 0; column < columns * (dual ? 2 : 1); column++)
{
if (columns % 2 == 1 && column % columns == columns / 2)
remapKeyBinding(oldSpecialAction++, column);
else
remapKeyBinding(oldNormalAction++, column);
}
void remapKeyBinding(int oldAction, int newAction)
{
var oldKeyBinding = oldKeyBindings.Find(kb => kb.ActionInt == oldAction);
if (oldKeyBinding != null)
migration.NewRealm.Add(new RealmKeyBinding(newAction, oldKeyBinding.KeyCombination, @"mania", variant));
}
}
break;
case 43:
{
// Clear default bindings for "Toggle FPS Display",
// as it conflicts with "Convert to Stream" in the editor.
// Only apply change if set to the conflicting bind
// i.e. has been manually rebound by the user.
var keyBindings = migration.NewRealm.All<RealmKeyBinding>();
var toggleFpsBind = keyBindings.FirstOrDefault(bind => bind.ActionInt == (int)GlobalAction.ToggleFPSDisplay);
if (toggleFpsBind != null && toggleFpsBind.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Shift, InputKey.Control, InputKey.F }))
migration.NewRealm.Remove(toggleFpsBind);
break;
}
case 45:
{
// Cycling beat snap divisors no longer requires holding shift (just control).
var keyBindings = migration.NewRealm.All<RealmKeyBinding>();
var nextBeatSnapBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.EditorCycleNextBeatSnapDivisor);
if (nextBeatSnapBinding != null && nextBeatSnapBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Shift, InputKey.Control, InputKey.MouseWheelLeft }))
migration.NewRealm.Remove(nextBeatSnapBinding);
var previousBeatSnapBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.EditorCyclePreviousBeatSnapDivisor);
if (previousBeatSnapBinding != null && previousBeatSnapBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Shift, InputKey.Control, InputKey.MouseWheelRight }))
migration.NewRealm.Remove(previousBeatSnapBinding);
break;
}
case 46:
{
// Stable direction didn't match.
var keyBindings = migration.NewRealm.All<RealmKeyBinding>();
var nextBeatSnapBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.EditorCycleNextBeatSnapDivisor);
if (nextBeatSnapBinding != null && nextBeatSnapBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Control, InputKey.MouseWheelDown }))
migration.NewRealm.Remove(nextBeatSnapBinding);
var previousBeatSnapBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.EditorCyclePreviousBeatSnapDivisor);
if (previousBeatSnapBinding != null && previousBeatSnapBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Control, InputKey.MouseWheelUp }))
migration.NewRealm.Remove(previousBeatSnapBinding);
break;
}
case 47:
{
var keyBindings = migration.NewRealm.All<RealmKeyBinding>();
var existingBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.AbsoluteScrollSongList);
if (existingBinding != null && existingBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.MouseRight }))
migration.NewRealm.Remove(existingBinding);
break;
}
case 48:
const int qualified = (int)BeatmapOnlineStatus.Qualified;
var beatmaps = migration.NewRealm.All<BeatmapInfo>().Where(b => b.StatusInt == qualified);
foreach (var beatmap in beatmaps)
beatmap.ResetOnlineInfo(resetOnlineId: false);
break;
case 49:
foreach (var score in migration.NewRealm.All<ScoreInfo>().Where(s => s.LegacyOnlineID == 0))
score.LegacyOnlineID = -1;
break;
}
Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms");
}
private string? getRulesetShortNameFromLegacyID(long rulesetId)
{
try
{
return new APIBeatmap.APIRuleset { OnlineID = (int)rulesetId }.ShortName;
}
catch
{
return null;
}
}
/// <summary>
/// Create a full realm backup.
/// </summary>
/// <param name="backupFilename">The filename for the backup.</param>
public void CreateBackup(string backupFilename)
{
if (realmRetrievalLock.CurrentCount != 0)
throw new InvalidOperationException($"Call {nameof(BlockAllOperations)} before creating a backup.");
createBackup(backupFilename);
}
private void createBackup(string backupFilename)
{
Logger.Log($"Creating full realm database backup at {backupFilename}", LoggingTarget.Database);
FileUtils.AttemptOperation(() =>
{
using (var source = storage.GetStream(Filename, mode: FileMode.Open))
{
// source may not exist.
if (source == null)
return;
using (var destination = storage.GetStream(backupFilename, FileAccess.Write, FileMode.CreateNew))
source.CopyTo(destination);
}
}, 20);
}
/// <summary>
/// Flush any active realm instances and block any further writes.
/// </summary>
/// <remarks>
/// This should be used in places we need to ensure no ongoing reads/writes are occurring with realm.
/// ie. to move the realm backing file to a new location.
/// </remarks>
/// <param name="reason">The reason for blocking. Used for logging purposes.</param>
/// <returns>An <see cref="IDisposable"/> which should be disposed to end the blocking section.</returns>
public IDisposable BlockAllOperations(string reason)
{
Logger.Log($@"Attempting to block all realm operations for {reason}.", LoggingTarget.Database);
if (!ThreadSafety.IsUpdateThread)
throw new InvalidOperationException(@$"{nameof(BlockAllOperations)} must be called from the update thread.");
ObjectDisposedException.ThrowIf(isDisposed, this);
SynchronizationContext? syncContext = null;
try
{
realmRetrievalLock.Wait();
if (hasInitialisedOnce)
{
syncContext = SynchronizationContext.Current;
// Before disposing the update context, clean up all subscriptions.
// Note that in the case of realm notification subscriptions, this is not really required (they will be cleaned up by disposal).
// In the case of custom subscriptions, we want them to fire before the update realm is disposed in case they do any follow-up work.
foreach (var action in customSubscriptionsResetMap.ToArray())
{
action.Value?.Dispose();
customSubscriptionsResetMap[action.Key] = null;
}
updateRealm?.Dispose();
updateRealm = null;
}
Logger.Log(@"Lock acquired for blocking operations", LoggingTarget.Database);
const int sleep_length = 200;
int timeSpent = 0;
try
{
// see https://github.com/realm/realm-dotnet/discussions/2657
while (!Compact())
{
Thread.Sleep(sleep_length);
timeSpent += sleep_length;
if (timeSpent > 5000)
throw new TimeoutException($@"Realm compact failed after {timeSpent / sleep_length} attempts over {timeSpent / 1000} seconds");
}
}
catch (RealmException e)
{
// Compact may fail if the realm is in a bad state.
// We still want to continue with the blocking operation, though.
Logger.Log($"Realm compact failed with error {e}", LoggingTarget.Database);
}
Logger.Log(@"Realm usage isolated via compact", LoggingTarget.Database);
// In order to ensure events arrive in the correct order, these *must* be fired post disposal of the update realm,
// and must be posted to the synchronization context.
// This is because realm may fire event callbacks between the `unregisterAllSubscriptions` and `updateRealm.Dispose`
// calls above.
syncContext?.Send(_ =>
{
// Flag ensures that we don't get in a deadlocked scenario due to a callback attempting to access `RealmAccess.Realm` or `RealmAccess.Run`
// and hitting `realmRetrievalLock` a second time. Generally such usages should not exist, and as such we throw when an attempt is made
// to use in this fashion.
isSendingNotificationResetEvents = true;
try
{
lock (notificationsResetMap)
{
foreach (var action in notificationsResetMap.Values)
action();
}
}
finally
{
isSendingNotificationResetEvents = false;
}
}, null);
}
catch
{
restoreOperation();
throw;
}
return new InvokeOnDisposal(restoreOperation);
void restoreOperation()
{
// Release of lock needs to happen here rather than on the update thread, as there may be another
// operation already blocking the update thread waiting for the blocking operation to complete.
Logger.Log(@"Restoring realm operations.", LoggingTarget.Database);
realmRetrievalLock.Release();
if (syncContext == null) return;
ManualResetEventSlim updateRealmReestablished = new ManualResetEventSlim();
// Post back to the update thread to revive any subscriptions.
// In the case we are on the update thread, let's also require this to run synchronously.
// This requirement is mostly due to test coverage, but shouldn't cause any harm.
if (ThreadSafety.IsUpdateThread)
{
syncContext.Send(_ =>
{
ensureUpdateRealm();
updateRealmReestablished.Set();
}, null);
}
else
{
syncContext.Post(_ =>
{
ensureUpdateRealm();
updateRealmReestablished.Set();
}, null);
}
// Wait for the post to complete to ensure a second `Migrate` operation doesn't start in the mean time.
// This is important to ensure `ensureUpdateRealm` is run before another blocking migration operation starts.
if (!updateRealmReestablished.Wait(10000))
throw new TimeoutException(@"Reestablishing update realm after block took too long");
}
}
// https://github.com/realm/realm-dotnet/blob/32f4ebcc88b3e80a3b254412665340cd9f3bd6b5/Realm/Realm/Extensions/ReflectionExtensions.cs#L46
private static string getMappedOrOriginalName(MemberInfo member) => member.GetCustomAttribute<MapToAttribute>()?.Mapping ?? member.Name;
private bool isDisposed;
public void Dispose()
{
if (!pendingAsyncWrites.Wait(10000))
Logger.Log("Realm took too long waiting on pending async writes", level: LogLevel.Error);
updateRealm?.Dispose();
if (!isDisposed)
{
// intentionally block realm retrieval indefinitely. this ensures that nothing can start consuming a new instance after disposal.
realmRetrievalLock.Wait();
realmRetrievalLock.Dispose();
isDisposed = true;
}
}
}
}