2021-01-21 19:53:16 +08:00
// 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.
2021-01-14 15:13:10 +08:00
using System ;
2022-01-21 17:50:25 +08:00
using System.Collections.Generic ;
2022-03-03 16:42:40 +08:00
using System.ComponentModel ;
2022-01-21 17:50:25 +08:00
using System.Diagnostics ;
2022-01-18 13:30:41 +08:00
using System.IO ;
2021-10-18 14:35:51 +08:00
using System.Linq ;
2022-03-03 16:42:40 +08:00
using System.Linq.Expressions ;
2021-11-05 16:01:00 +08:00
using System.Reflection ;
2021-01-07 13:07:36 +08:00
using System.Threading ;
2022-03-01 17:31:33 +08:00
using System.Threading.Tasks ;
2022-03-29 10:40:58 +08:00
using osu.Framework ;
2021-01-22 16:28:47 +08:00
using osu.Framework.Allocation ;
2021-07-04 16:59:39 +08:00
using osu.Framework.Development ;
2022-07-27 22:19:00 +08:00
using osu.Framework.Extensions ;
2021-10-29 10:14:22 +08:00
using osu.Framework.Input.Bindings ;
2021-01-12 13:36:35 +08:00
using osu.Framework.Logging ;
2021-01-07 13:07:36 +08:00
using osu.Framework.Platform ;
using osu.Framework.Statistics ;
2022-03-24 21:28:26 +08:00
using osu.Framework.Threading ;
2021-11-19 18:07:21 +08:00
using osu.Game.Beatmaps ;
2022-03-01 17:30:55 +08:00
using osu.Game.Configuration ;
2021-10-29 10:14:22 +08:00
using osu.Game.Input.Bindings ;
2021-10-18 14:35:51 +08:00
using osu.Game.Models ;
2022-09-15 15:39:59 +08:00
using osu.Game.Online.API.Requests.Responses ;
2021-11-23 12:00:33 +08:00
using osu.Game.Rulesets ;
2022-08-07 14:16:33 +08:00
using osu.Game.Rulesets.Mods ;
2022-01-13 12:40:09 +08:00
using osu.Game.Scoring ;
2022-03-01 17:30:55 +08:00
using osu.Game.Skinning ;
2021-01-07 13:07:36 +08:00
using Realms ;
2022-01-19 09:58:59 +08:00
using Realms.Exceptions ;
2021-01-07 13:07:36 +08:00
namespace osu.Game.Database
{
2021-09-30 22:42:40 +08:00
/// <summary>
2022-01-25 12:56:47 +08:00
/// A factory which provides safe access to the realm storage backend.
2021-09-30 22:42:40 +08:00
/// </summary>
2022-01-24 18:59:58 +08:00
public class RealmAccess : IDisposable
2021-01-07 13:07:36 +08:00
{
private readonly Storage storage ;
2021-01-13 17:24:19 +08:00
2021-01-12 13:36:35 +08:00
/// <summary>
2021-09-30 22:42:40 +08:00
/// The filename of this realm.
2021-01-12 13:36:35 +08:00
/// </summary>
2021-09-30 22:42:40 +08:00
public readonly string Filename ;
2022-03-24 21:28:26 +08:00
private readonly SynchronizationContext ? updateThreadSyncContext ;
2021-10-18 14:35:51 +08:00
/// <summary>
/// Version history:
2021-11-22 14:23:16 +08:00
/// 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.
2021-11-22 17:07:28 +08:00
/// 10 2021-11-22 Use ShortName instead of RulesetID for ruleset settings.
2021-11-22 17:34:04 +08:00
/// 11 2021-11-22 Use ShortName instead of RulesetID for ruleset key bindings.
2021-11-24 17:45:34 +08:00
/// 12 2021-11-24 Add Status to RealmBeatmapSet.
2022-01-13 12:28:46 +08:00
/// 13 2022-01-13 Final migration of beatmaps and scores to realm (multiple new storage fields).
2022-03-01 15:22:51 +08:00
/// 14 2022-03-01 Added BeatmapUserSettings to BeatmapInfo.
2022-07-13 15:36:43 +08:00
/// 15 2022-07-13 Added LastPlayed to BeatmapInfo.
2022-07-15 15:11:11 +08:00
/// 16 2022-07-15 Removed HasReplay from ScoreInfo.
2022-07-18 15:16:59 +08:00
/// 17 2022-07-16 Added CountryCode to RealmUser.
2022-06-20 17:59:08 +08:00
/// 18 2022-07-19 Added OnlineMD5Hash and LastOnlineUpdate to BeatmapInfo.
2022-07-19 18:37:04 +08:00
/// 19 2022-07-19 Added DateSubmitted and DateRanked to BeatmapSetInfo.
2022-07-21 16:39:07 +08:00
/// 20 2022-07-21 Added LastAppliedDifficultyVersion to RulesetInfo, changed default value of BeatmapInfo.StarRating to -1.
2022-07-27 22:19:00 +08:00
/// 21 2022-07-27 Migrate collections to realm (BeatmapCollection).
2022-07-24 00:01:11 +08:00
/// 22 2022-07-31 Added ModPreset.
2022-08-16 15:01:19 +08:00
/// 23 2022-08-01 Added LastLocalUpdate to BeatmapInfo.
2022-08-22 18:45:19 +08:00
/// 24 2022-08-22 Added MaximumStatistics to ScoreInfo.
2022-09-15 15:17:48 +08:00
/// 25 2022-09-18 Remove skins to add with new naming.
2023-02-07 16:52:47 +08:00
/// 26 2023-02-05 Added BeatmapHash to ScoreInfo.
2021-10-18 14:35:51 +08:00
/// </summary>
2023-02-06 02:55:50 +08:00
private const int schema_version = 26 ;
2021-01-07 13:07:36 +08:00
2021-06-24 13:37:26 +08:00
/// <summary>
2022-01-25 12:56:47 +08:00
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
2021-06-24 13:37:26 +08:00
/// </summary>
2022-01-25 12:56:47 +08:00
private readonly SemaphoreSlim realmRetrievalLock = new SemaphoreSlim ( 1 ) ;
2021-06-24 13:37:26 +08:00
2022-07-04 13:59:44 +08:00
/// <summary>
/// <c>true</c> when the current thread has already entered the <see cref="realmRetrievalLock"/>.
/// </summary>
private readonly ThreadLocal < bool > currentThreadHasRealmRetrievalLock = new ThreadLocal < bool > ( ) ;
2021-11-29 15:18:57 +08:00
2022-01-24 13:48:55 +08:00
/// <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 ? > ( ) ;
2022-01-24 13:37:36 +08:00
2022-01-24 13:48:55 +08:00
/// <summary>
/// Holds a map of functions registered via <see cref="RegisterForNotifications{T}"/> and a coinciding action which when triggered,
2022-01-25 12:56:47 +08:00
/// 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
2022-01-24 13:48:55 +08:00
/// managed realm objects from a previous firing.
/// </summary>
2022-01-24 16:58:53 +08:00
private readonly Dictionary < Func < Realm , IDisposable ? > , Action > notificationsResetMap = new Dictionary < Func < Realm , IDisposable ? > , Action > ( ) ;
2022-01-24 13:37:36 +08:00
2022-01-24 19:11:36 +08:00
private static readonly GlobalStatistic < int > realm_instances_created = GlobalStatistics . Get < int > ( @"Realm" , @"Instances (Created)" ) ;
2021-01-22 16:28:47 +08:00
2022-01-24 19:23:10 +08:00
private static readonly GlobalStatistic < int > total_subscriptions = GlobalStatistics . Get < int > ( @"Realm" , @"Subscriptions" ) ;
2022-03-01 17:30:55 +08:00
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)" ) ;
2022-01-24 19:11:36 +08:00
private Realm ? updateRealm ;
2021-01-13 16:34:44 +08:00
2022-06-27 16:59:40 +08:00
/// <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 ;
2022-01-26 16:09:28 +08:00
private bool isSendingNotificationResetEvents ;
2022-01-24 19:11:36 +08:00
public Realm Realm = > ensureUpdateRealm ( ) ;
2022-01-21 21:40:18 +08:00
2022-03-08 15:06:42 +08:00
private const string realm_extension = @".realm" ;
2022-01-24 19:11:36 +08:00
private Realm ensureUpdateRealm ( )
2021-01-07 13:07:36 +08:00
{
2022-01-26 16:09:28 +08:00
if ( isSendingNotificationResetEvents )
throw new InvalidOperationException ( "Cannot retrieve a realm context from a notification callback during a blocking operation." ) ;
2022-01-21 21:40:18 +08:00
if ( ! ThreadSafety . IsUpdateThread )
2022-01-24 19:11:36 +08:00
throw new InvalidOperationException ( @ $"Use {nameof(getRealmInstance)} when performing realm operations from a non-update thread" ) ;
2021-07-04 16:59:39 +08:00
2022-06-28 15:54:53 +08:00
if ( updateRealm = = null )
2022-01-21 21:40:18 +08:00
{
2022-06-28 15:54:53 +08:00
updateRealm = getRealmInstance ( ) ;
hasInitialisedOnce = true ;
2022-01-24 19:11:36 +08:00
2022-06-28 15:54:53 +08:00
Logger . Log ( @ $"Opened realm ""{updateRealm.Config.DatabasePath}"" at version {updateRealm.Config.SchemaVersion}" ) ;
2022-01-21 17:50:25 +08:00
2022-06-28 15:54:53 +08:00
// Resubscribe any subscriptions
2022-06-29 21:39:05 +08:00
foreach ( var action in customSubscriptionsResetMap . Keys . ToArray ( ) )
2022-06-28 15:54:53 +08:00
registerSubscription ( action ) ;
}
2021-10-01 02:45:00 +08:00
2022-06-28 15:54:53 +08:00
Debug . Assert ( updateRealm ! = null ) ;
2022-01-21 19:45:10 +08:00
2022-06-28 15:54:53 +08:00
return updateRealm ;
2021-01-07 14:41:29 +08:00
}
2022-01-21 17:13:21 +08:00
internal static bool CurrentThreadSubscriptionsAllowed = > current_thread_subscriptions_allowed . Value ;
private static readonly ThreadLocal < bool > current_thread_subscriptions_allowed = new ThreadLocal < bool > ( ) ;
2021-11-23 16:47:43 +08:00
/// <summary>
2022-01-24 19:11:36 +08:00
/// Construct a new instance.
2021-11-23 16:47:43 +08:00
/// </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>
2022-03-24 21:28:26 +08:00
/// <param name="updateThread">The game update thread, used to post realm operations into a thread-safe context.</param>
2022-09-15 15:39:59 +08:00
public RealmAccess ( Storage storage , string filename , GameThread ? updateThread = null )
2021-01-07 14:41:29 +08:00
{
2021-01-13 16:34:44 +08:00
this . storage = storage ;
2021-01-07 13:07:36 +08:00
2022-03-24 21:28:26 +08:00
updateThreadSyncContext = updateThread ? . SynchronizationContext ? ? SynchronizationContext . Current ;
2021-09-30 22:42:40 +08:00
Filename = filename ;
2021-01-07 13:07:36 +08:00
2021-09-30 22:42:40 +08:00
if ( ! Filename . EndsWith ( realm_extension , StringComparison . Ordinal ) )
Filename + = realm_extension ;
2021-09-30 22:45:09 +08:00
2022-08-05 16:36:48 +08:00
#if DEBUG
2022-08-05 17:23:41 +08:00
if ( ! DebugUtils . IsNUnitRunning )
applyFilenameSchemaSuffix ( ref Filename ) ;
2022-08-05 16:36:48 +08:00
#endif
2023-05-31 05:17:07 +08:00
// `prepareFirstRealmAccess()` triggers the first `getRealmInstance` call, which will implicitly run realm migrations and bring the schema up-to-date.
2023-05-30 11:58:22 +08:00
using ( var realm = prepareFirstRealmAccess ( ) )
cleanupPendingDeletions ( realm ) ;
2021-09-30 22:45:09 +08:00
}
2022-08-05 16:36:48 +08:00
/// <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>
2022-08-05 16:48:51 +08:00
private void applyFilenameSchemaSuffix ( ref string filename )
2022-08-05 16:36:48 +08:00
{
2022-08-05 16:48:51 +08:00
string originalFilename = filename ;
2022-08-05 16:36:48 +08:00
2022-08-05 16:48:51 +08:00
filename = getVersionedFilename ( schema_version ) ;
2022-08-05 16:36:48 +08:00
2022-08-05 16:48:51 +08:00
// First check if the current realm version already exists...
2022-08-05 16:36:48 +08:00
if ( storage . Exists ( filename ) )
2022-08-05 16:48:51 +08:00
return ;
2022-08-05 16:36:48 +08:00
2022-08-05 16:48:51 +08:00
// Check for a previous version we can use as a base database to migrate from...
2022-08-05 16:36:48 +08:00
for ( int i = schema_version - 1 ; i > = 0 ; i - - )
{
2022-08-05 17:27:29 +08:00
string previousFilename = getVersionedFilename ( i ) ;
2022-08-05 16:36:48 +08:00
2022-08-05 17:27:29 +08:00
if ( storage . Exists ( previousFilename ) )
2022-08-05 16:36:48 +08:00
{
2022-08-05 17:27:29 +08:00
copyPreviousVersion ( previousFilename , filename ) ;
return ;
2022-08-05 16:36:48 +08:00
}
}
2022-08-05 16:48:51 +08:00
// Finally, check for a non-versioned file exists (aka before this method was added)...
if ( storage . Exists ( originalFilename ) )
2022-08-05 17:27:29 +08:00
copyPreviousVersion ( originalFilename , filename ) ;
void copyPreviousVersion ( string previousFilename , string newFilename )
2022-08-05 16:48:51 +08:00
{
2022-08-05 17:27:29 +08:00
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 ) ;
}
2022-08-05 16:48:51 +08:00
}
2022-08-05 16:36:48 +08:00
2022-08-05 16:48:51 +08:00
string getVersionedFilename ( int version ) = > originalFilename . Replace ( realm_extension , $"_{version}{realm_extension}" ) ;
2022-08-05 16:36:48 +08:00
}
2022-03-08 15:06:42 +08:00
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 ) ;
2022-03-30 12:34:48 +08:00
Logger . Log ( $@"To perform recovery, delete {OsuGameBase.CLIENT_DATABASE_FILENAME} while osu! is not running." , LoggingTarget . Database ) ;
2022-03-08 15:06:42 +08:00
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
{
2022-03-08 17:19:54 +08:00
using ( Realm . GetInstance ( getConfiguration ( recoveryFilename ) ) )
2022-03-08 15:06:42 +08:00
{
// 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.
2022-07-04 15:27:49 +08:00
createBackup ( $"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_newer_version_before_recovery{realm_extension}" ) ;
2022-03-08 15:06:42 +08:00
storage . Delete ( Filename ) ;
using ( var inputStream = storage . GetStream ( recoveryFilename ) )
2022-05-16 17:03:53 +08:00
using ( var outputStream = storage . CreateFileSafely ( Filename ) )
2022-03-08 15:06:42 +08:00
inputStream . CopyTo ( outputStream ) ;
storage . Delete ( recoveryFilename ) ;
Logger . Log ( @"Recovery complete!" , LoggingTarget . Database ) ;
}
2023-05-30 11:58:22 +08:00
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
{
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 backup again. We can presume that the first backup is the one we care about.
if ( ! storage . Exists ( newerVersionFilename ) )
createBackup ( newerVersionFilename ) ;
}
else
{
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 )
2021-09-30 22:45:09 +08:00
{
2023-05-31 18:39:43 +08:00
try
2021-09-30 22:45:09 +08:00
{
2023-05-31 18:39:43 +08:00
using ( var transaction = realm . BeginWrite ( ) )
{
var pendingDeleteScores = realm . All < ScoreInfo > ( ) . Where ( s = > s . DeletePending ) ;
2022-01-13 12:40:09 +08:00
2023-05-31 18:39:43 +08:00
foreach ( var score in pendingDeleteScores )
realm . Remove ( score ) ;
2022-01-13 12:40:09 +08:00
2023-05-31 18:39:43 +08:00
var pendingDeleteSets = realm . All < BeatmapSetInfo > ( ) . Where ( s = > s . DeletePending ) ;
2021-09-30 22:45:09 +08:00
2023-05-31 18:39:43 +08:00
foreach ( var beatmapSet in pendingDeleteSets )
2022-01-12 14:09:56 +08:00
{
2023-05-31 18:39:43 +08:00
foreach ( var beatmap in beatmapSet . Beatmaps )
{
// Cascade delete related scores, else they will have a null beatmap against the model's spec.
foreach ( var score in beatmap . Scores )
realm . Remove ( score ) ;
2021-09-30 22:45:09 +08:00
2023-05-31 18:39:43 +08:00
realm . Remove ( beatmap . Metadata ) ;
realm . Remove ( beatmap ) ;
}
realm . Remove ( beatmapSet ) ;
2022-01-12 14:09:56 +08:00
}
2023-05-31 18:39:43 +08:00
var pendingDeleteSkins = realm . All < SkinInfo > ( ) . Where ( s = > s . DeletePending ) ;
2021-09-30 22:45:09 +08:00
2023-05-31 18:39:43 +08:00
foreach ( var s in pendingDeleteSkins )
realm . Remove ( s ) ;
2021-11-29 16:59:41 +08:00
2023-05-31 18:39:43 +08:00
var pendingDeletePresets = realm . All < ModPreset > ( ) . Where ( s = > s . DeletePending ) ;
2021-11-29 16:59:41 +08:00
2023-05-31 18:39:43 +08:00
foreach ( var s in pendingDeletePresets )
realm . Remove ( s ) ;
2022-08-07 14:16:33 +08:00
2023-05-31 18:39:43 +08:00
transaction . Commit ( ) ;
}
2022-08-07 14:16:33 +08:00
2023-05-31 18:39:43 +08:00
// 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." ) ;
2021-09-30 22:45:09 +08:00
}
2021-06-24 13:37:26 +08:00
}
2021-01-13 16:34:44 +08:00
2021-09-30 22:42:40 +08:00
/// <summary>
/// Compact this realm.
/// </summary>
/// <returns></returns>
2022-03-29 10:40:58 +08:00
public bool Compact ( )
{
try
{
return Realm . Compact ( getConfiguration ( ) ) ;
}
// Catch can be removed along with entity framework. Is specifically to allow a failure message to arrive to the user (see similar catches in EFToRealmMigrator).
catch ( AggregateException ae ) when ( RuntimeInfo . OS = = RuntimeInfo . Platform . macOS & & ae . Flatten ( ) . InnerException is TypeInitializationException )
{
return true ;
}
}
2021-09-30 22:42:40 +08:00
2022-01-21 00:33:45 +08:00
/// <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 )
2022-03-01 17:30:55 +08:00
{
total_reads_update . Value + + ;
2022-01-24 19:11:36 +08:00
return action ( Realm ) ;
2022-03-01 17:30:55 +08:00
}
2022-01-21 00:33:45 +08:00
2022-03-01 17:30:55 +08:00
total_reads_async . Value + + ;
2022-01-24 19:11:36 +08:00
using ( var realm = getRealmInstance ( ) )
2022-01-21 00:33:45 +08:00
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 )
2022-03-01 17:30:55 +08:00
{
total_reads_update . Value + + ;
2022-01-24 19:11:36 +08:00
action ( Realm ) ;
2022-03-01 17:30:55 +08:00
}
2022-01-21 00:33:45 +08:00
else
{
2022-03-01 17:30:55 +08:00
total_reads_async . Value + + ;
2022-01-24 19:11:36 +08:00
using ( var realm = getRealmInstance ( ) )
2022-01-21 00:33:45 +08:00
action ( realm ) ;
2022-05-10 19:47:26 +08:00
}
}
/// <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 ) ;
2022-01-21 00:33:45 +08:00
}
}
2022-01-21 16:08:02 +08:00
/// <summary>
/// Write changes to realm.
/// </summary>
/// <param name="action">The work to run.</param>
public void Write ( Action < Realm > action )
{
if ( ThreadSafety . IsUpdateThread )
2022-03-01 17:30:55 +08:00
{
total_writes_update . Value + + ;
2022-01-24 19:11:36 +08:00
Realm . Write ( action ) ;
2022-03-01 17:30:55 +08:00
}
2022-01-21 16:08:02 +08:00
else
{
2022-03-01 17:30:55 +08:00
total_writes_async . Value + + ;
2022-01-24 19:11:36 +08:00
using ( var realm = getRealmInstance ( ) )
2022-01-21 16:08:02 +08:00
realm . Write ( action ) ;
}
}
2022-06-27 18:20:28 +08:00
private readonly CountdownEvent pendingAsyncWrites = new CountdownEvent ( 0 ) ;
2022-03-01 17:31:33 +08:00
/// <summary>
/// Write changes to realm asynchronously, guaranteeing order of execution.
/// </summary>
/// <param name="action">The work to run.</param>
2022-06-21 16:15:25 +08:00
public Task WriteAsync ( Action < Realm > action )
2022-03-01 17:31:33 +08:00
{
2022-06-27 18:34:28 +08:00
if ( isDisposed )
throw new ObjectDisposedException ( nameof ( RealmAccess ) ) ;
2022-06-27 18:20:28 +08:00
// 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." ) ;
2022-06-21 16:15:25 +08:00
2022-06-28 15:54:53 +08:00
// CountdownEvent will fail if already at zero.
if ( ! pendingAsyncWrites . TryAddCount ( ) )
pendingAsyncWrites . Reset ( 1 ) ;
2022-06-21 16:15:25 +08:00
2022-06-28 15:54:53 +08:00
// 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 + + ;
2022-06-27 18:20:28 +08:00
2022-06-28 15:54:53 +08:00
// 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]).
2022-12-16 17:16:26 +08:00
await realm . WriteAsync ( ( ) = > action ( realm ) ) . ConfigureAwait ( false ) ;
2022-06-27 18:20:28 +08:00
2022-06-28 15:54:53 +08:00
pendingAsyncWrites . Signal ( ) ;
} ) ;
2022-06-27 18:20:28 +08:00
2022-06-28 15:54:53 +08:00
return writeTask ;
2022-03-01 17:31:33 +08:00
}
2022-01-24 16:52:36 +08:00
/// <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"/>.
///
2022-01-24 19:11:36 +08:00
/// In addition to the documented realm behaviour, we have the additional requirement of handling subscriptions over potential realm instance recycle.
2022-01-24 16:52:36 +08:00
/// When this happens, callback events will be automatically fired:
2022-01-24 19:11:36 +08:00
/// - 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.
2022-01-24 16:52:36 +08:00
/// </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>
2022-01-24 17:05:30 +08:00
/// <seealso cref="IRealmCollection{T}.SubscribeForNotifications"/>
2022-01-24 16:52:36 +08:00
public IDisposable RegisterForNotifications < T > ( Func < Realm , IQueryable < T > > query , NotificationCallbackDelegate < T > callback )
2022-01-23 18:42:26 +08:00
where T : RealmObjectBase
{
2022-06-28 15:54:53 +08:00
Func < Realm , IDisposable ? > action = realm = > query ( realm ) . QueryAsyncWithNotifications ( callback ) ;
2022-01-24 13:48:55 +08:00
2022-06-29 19:56:01 +08:00
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 EmptyRealmSet < T > ( ) , null , null ) ) ;
}
2022-06-28 15:54:53 +08:00
return RegisterCustomSubscription ( action ) ;
2022-01-23 18:42:26 +08:00
}
2022-01-21 17:50:25 +08:00
2022-03-03 16:42:40 +08:00
/// <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
{
2022-06-24 20:25:23 +08:00
return RegisterCustomSubscription ( _ = >
2022-03-03 16:42:40 +08:00
{
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 ) ;
2022-12-16 17:16:26 +08:00
void onPropertyChanged ( object? sender , PropertyChangedEventArgs args )
2022-03-03 16:42:40 +08:00
{
if ( args . PropertyName = = propertyName )
onChanged ( propLookupCompiled ( model ) ) ;
}
} ) ;
static string getMemberName ( Expression < Func < TModel , TProperty > > expression )
{
if ( ! ( expression is LambdaExpression lambda ) )
2022-03-04 03:21:09 +08:00
throw new ArgumentException ( "Outermost expression must be a lambda expression" , nameof ( expression ) ) ;
2022-03-03 16:42:40 +08:00
if ( ! ( lambda . Body is MemberExpression memberExpression ) )
2022-03-04 03:21:09 +08:00
throw new ArgumentException ( "Lambda body must be a member access expression" , nameof ( expression ) ) ;
2022-03-03 16:42:40 +08:00
// 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 ] )
2022-03-04 03:21:09 +08:00
throw new ArgumentException ( "Nested access expressions are not supported" , nameof ( expression ) ) ;
2022-03-03 16:42:40 +08:00
return memberExpression . Member . Name ;
}
}
2022-01-21 17:13:21 +08:00
/// <summary>
2022-01-24 19:11:36 +08:00
/// Run work on realm that will be run every time the update thread realm instance gets recycled.
2022-01-21 17:13:21 +08:00
/// </summary>
2022-01-21 17:50:25 +08:00
/// <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>
2022-01-24 13:37:36 +08:00
public IDisposable RegisterCustomSubscription ( Func < Realm , IDisposable ? > action )
2022-01-21 17:13:21 +08:00
{
2022-03-24 21:28:26 +08:00
if ( updateThreadSyncContext = = null )
throw new InvalidOperationException ( "Attempted to register a realm subscription before update thread registration." ) ;
2022-01-23 19:38:34 +08:00
2022-01-24 19:23:10 +08:00
total_subscriptions . Value + + ;
2022-03-24 21:28:26 +08:00
if ( ThreadSafety . IsUpdateThread )
updateThreadSyncContext . Send ( _ = > registerSubscription ( action ) , null ) ;
else
updateThreadSyncContext . Post ( _ = > registerSubscription ( action ) , null ) ;
2022-01-21 17:50:25 +08:00
2022-01-24 13:48:55 +08:00
// 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).
2022-01-21 17:50:25 +08:00
return new InvokeOnDisposal ( ( ) = >
2022-01-21 17:13:21 +08:00
{
2022-01-23 19:38:34 +08:00
if ( ThreadSafety . IsUpdateThread )
2022-03-24 21:28:26 +08:00
updateThreadSyncContext . Send ( _ = > unsubscribe ( ) , null ) ;
2022-01-23 19:38:34 +08:00
else
2022-03-24 21:28:26 +08:00
updateThreadSyncContext . Post ( _ = > unsubscribe ( ) , null ) ;
2022-01-23 19:38:34 +08:00
void unsubscribe ( )
2022-01-21 17:50:25 +08:00
{
2022-06-28 15:54:53 +08:00
if ( customSubscriptionsResetMap . TryGetValue ( action , out var unsubscriptionAction ) )
2022-01-23 17:00:24 +08:00
{
2022-06-28 15:54:53 +08:00
unsubscriptionAction ? . Dispose ( ) ;
customSubscriptionsResetMap . Remove ( action ) ;
2022-06-29 19:56:01 +08:00
lock ( notificationsResetMap )
{
notificationsResetMap . Remove ( action ) ;
}
2022-06-28 15:54:53 +08:00
total_subscriptions . Value - - ;
2022-01-23 17:00:24 +08:00
}
2022-01-21 17:50:25 +08:00
}
} ) ;
}
private void registerSubscription ( Func < Realm , IDisposable ? > action )
{
Debug . Assert ( ThreadSafety . IsUpdateThread ) ;
2022-06-28 15:54:53 +08:00
// 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 ;
2022-01-24 16:45:31 +08:00
2022-06-28 15:54:53 +08:00
Debug . Assert ( ! customSubscriptionsResetMap . TryGetValue ( action , out var found ) | | found = = null ) ;
2022-01-23 17:13:28 +08:00
2022-06-28 15:54:53 +08:00
current_thread_subscriptions_allowed . Value = true ;
customSubscriptionsResetMap [ action ] = action ( realm ) ;
current_thread_subscriptions_allowed . Value = false ;
2022-01-21 17:13:21 +08:00
}
2022-01-24 19:11:36 +08:00
private Realm getRealmInstance ( )
2021-01-12 13:36:35 +08:00
{
2021-10-15 12:58:14 +08:00
if ( isDisposed )
2022-01-24 18:59:58 +08:00
throw new ObjectDisposedException ( nameof ( RealmAccess ) ) ;
2021-10-01 02:46:53 +08:00
2021-11-29 17:26:37 +08:00
bool tookSemaphoreLock = false ;
2021-06-24 13:37:26 +08:00
try
{
2022-07-04 13:59:44 +08:00
// Ensure that the thread that currently has the `realmRetrievalLock` can retrieve nested contexts and not deadlock on itself.
if ( ! currentThreadHasRealmRetrievalLock . Value )
2021-11-29 17:26:37 +08:00
{
2022-01-25 12:56:47 +08:00
realmRetrievalLock . Wait ( ) ;
2022-07-04 13:59:44 +08:00
currentThreadHasRealmRetrievalLock . Value = true ;
2021-11-29 17:26:37 +08:00
tookSemaphoreLock = true ;
}
else
{
2022-01-24 19:11:36 +08:00
// 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`.
2021-11-29 17:26:37 +08:00
}
2021-01-22 16:28:47 +08:00
2022-01-24 19:11:36 +08:00
realm_instances_created . Value + + ;
2021-01-12 13:36:35 +08:00
2021-09-30 22:42:40 +08:00
return Realm . GetInstance ( getConfiguration ( ) ) ;
2021-06-24 13:37:26 +08:00
}
2022-03-29 10:40:58 +08:00
// Catch can be removed along with entity framework. Is specifically to allow a failure message to arrive to the user (see similar catches in EFToRealmMigrator).
catch ( AggregateException ae ) when ( RuntimeInfo . OS = = RuntimeInfo . Platform . macOS & & ae . Flatten ( ) . InnerException is TypeInitializationException )
{
return Realm . GetInstance ( ) ;
}
2021-06-24 13:37:26 +08:00
finally
2021-01-12 13:36:35 +08:00
{
2021-11-29 17:26:37 +08:00
if ( tookSemaphoreLock )
{
2022-01-25 12:56:47 +08:00
realmRetrievalLock . Release ( ) ;
2022-07-04 13:59:44 +08:00
currentThreadHasRealmRetrievalLock . Value = false ;
2021-11-29 17:26:37 +08:00
}
2021-06-24 13:37:26 +08:00
}
2021-01-12 13:36:35 +08:00
}
2022-03-08 15:06:42 +08:00
private RealmConfiguration getConfiguration ( string? filename = null )
2021-06-28 15:14:14 +08:00
{
2022-01-18 10:39:22 +08:00
// 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 ) ;
2022-03-08 15:06:42 +08:00
return new RealmConfiguration ( storage . GetFullPath ( filename ? ? Filename , true ) )
2021-09-30 22:42:40 +08:00
{
SchemaVersion = schema_version ,
MigrationCallback = onMigration ,
2022-01-18 10:39:22 +08:00
FallbackPipePath = tempPathLocation ,
2021-09-30 22:42:40 +08:00
} ;
2021-06-28 15:14:14 +08:00
}
2021-01-13 17:24:19 +08:00
private void onMigration ( Migration migration , ulong lastSchemaVersion )
2021-01-07 13:07:36 +08:00
{
2021-11-22 17:37:28 +08:00
for ( ulong i = lastSchemaVersion + 1 ; i < = schema_version ; i + + )
2021-11-04 17:39:23 +08:00
applyMigrationsForVersion ( migration , i ) ;
}
2021-11-22 17:37:28 +08:00
private void applyMigrationsForVersion ( Migration migration , ulong targetVersion )
2021-11-04 17:39:23 +08:00
{
2021-11-22 17:37:28 +08:00
switch ( targetVersion )
2021-11-04 17:32:50 +08:00
{
2021-11-04 17:39:23 +08:00
case 7 :
2021-11-19 18:07:21 +08:00
convertOnlineIDs < BeatmapInfo > ( ) ;
convertOnlineIDs < BeatmapSetInfo > ( ) ;
convertOnlineIDs < RulesetInfo > ( ) ;
2021-11-04 17:32:50 +08:00
2021-11-04 17:39:23 +08:00
void convertOnlineIDs < T > ( ) where T : RealmObject
{
2021-11-05 16:01:00 +08:00
string className = getMappedOrOriginalName ( typeof ( T ) ) ;
2021-11-04 17:32:50 +08:00
2021-11-04 17:39:23 +08:00
// 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 ;
2021-11-04 17:32:50 +08:00
2021-11-04 17:39:23 +08:00
var oldItems = migration . OldRealm . DynamicApi . All ( className ) ;
var newItems = migration . NewRealm . DynamicApi . All ( className ) ;
2021-11-04 17:32:50 +08:00
2021-11-04 17:39:23 +08:00
int itemCount = newItems . Count ( ) ;
2021-11-04 17:32:50 +08:00
2021-11-04 17:39:23 +08:00
for ( int i = 0 ; i < itemCount ; i + + )
{
dynamic? oldItem = oldItems . ElementAt ( i ) ;
dynamic? newItem = newItems . ElementAt ( i ) ;
2021-10-29 10:14:22 +08:00
2021-11-04 17:39:23 +08:00
long? nullableOnlineID = oldItem ? . OnlineID ;
newItem . OnlineID = ( int ) ( nullableOnlineID ? ? - 1 ) ;
}
}
2021-10-29 10:14:22 +08:00
2021-11-04 17:39:23 +08:00
break ;
2021-10-29 10:14:22 +08:00
2021-11-04 17:39:23 +08:00
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 > ( ) ;
2021-10-18 14:35:51 +08:00
2021-11-04 17:39:23 +08:00
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 ) ;
2021-10-18 14:35:51 +08:00
2021-11-04 17:39:23 +08:00
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.
2021-11-19 18:07:21 +08:00
string metadataClassName = getMappedOrOriginalName ( typeof ( BeatmapMetadata ) ) ;
2021-11-09 13:51:06 +08:00
// May be coming from a version before `RealmBeatmapMetadata` existed.
2021-11-09 16:42:03 +08:00
if ( ! migration . OldRealm . Schema . TryFindObjectSchema ( metadataClassName , out _ ) )
2021-11-09 13:51:06 +08:00
return ;
2021-11-09 16:42:03 +08:00
var oldMetadata = migration . OldRealm . DynamicApi . All ( metadataClassName ) ;
2021-11-19 18:07:21 +08:00
var newMetadata = migration . NewRealm . All < BeatmapMetadata > ( ) ;
2021-10-18 14:35:51 +08:00
2021-11-05 17:24:07 +08:00
int metadataCount = newMetadata . Count ( ) ;
2021-10-18 14:35:51 +08:00
2021-11-05 17:24:07 +08:00
for ( int i = 0 ; i < metadataCount ; i + + )
2021-10-18 14:35:51 +08:00
{
2021-11-05 16:02:23 +08:00
dynamic? oldItem = oldMetadata . ElementAt ( i ) ;
var newItem = newMetadata . ElementAt ( i ) ;
2021-10-18 14:35:51 +08:00
2021-11-04 17:39:23 +08:00
string username = oldItem . Author ;
newItem . Author = new RealmUser
{
Username = username
} ;
2021-10-18 14:35:51 +08:00
}
2021-11-04 17:39:23 +08:00
2021-11-22 17:07:28 +08:00
break ;
case 10 :
string rulesetSettingClassName = getMappedOrOriginalName ( typeof ( RealmRulesetSetting ) ) ;
2021-11-28 22:00:40 +08:00
if ( ! migration . OldRealm . Schema . TryFindObjectSchema ( rulesetSettingClassName , out _ ) )
return ;
2021-11-22 17:07:28 +08:00
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 ;
2021-11-23 15:27:28 +08:00
string? rulesetName = getRulesetShortNameFromLegacyID ( rulesetId ) ;
2021-11-22 17:34:04 +08:00
if ( string . IsNullOrEmpty ( rulesetName ) )
migration . NewRealm . Remove ( newItem ) ;
else
newItem . RulesetName = rulesetName ;
}
break ;
case 11 :
string keyBindingClassName = getMappedOrOriginalName ( typeof ( RealmKeyBinding ) ) ;
2021-11-28 22:00:40 +08:00
if ( ! migration . OldRealm . Schema . TryFindObjectSchema ( keyBindingClassName , out _ ) )
return ;
2021-11-22 17:34:04 +08:00
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 ;
2021-11-23 16:48:25 +08:00
string? rulesetName = getRulesetShortNameFromLegacyID ( rulesetId ) ;
2021-11-22 17:07:28 +08:00
if ( string . IsNullOrEmpty ( rulesetName ) )
migration . NewRealm . Remove ( newItem ) ;
else
newItem . RulesetName = rulesetName ;
}
2021-11-04 17:39:23 +08:00
break ;
2022-03-01 15:59:33 +08:00
case 14 :
foreach ( var beatmap in migration . NewRealm . All < BeatmapInfo > ( ) )
beatmap . UserSettings = new BeatmapUserSettings ( ) ;
2022-07-21 16:39:07 +08:00
break ;
case 20 :
2022-07-21 17:27:31 +08:00
// As we now have versioned difficulty calculations, let's reset
// all star ratings and have `BackgroundBeatmapProcessor` recalculate them.
2022-07-21 16:39:07 +08:00
foreach ( var beatmap in migration . NewRealm . All < BeatmapInfo > ( ) )
2022-07-21 17:27:31 +08:00
beatmap . StarRating = - 1 ;
2022-07-21 16:39:07 +08:00
2022-03-01 15:59:33 +08:00
break ;
2022-07-27 22:19:00 +08:00
case 21 :
2022-07-31 00:06:55 +08:00
// 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 ) ;
2022-07-27 22:19:00 +08:00
2022-07-31 00:06:55 +08:00
if ( legacyCollectionImporter . GetAvailableCount ( storage ) . GetResultSafely ( ) > 0 )
{
2022-10-28 12:07:47 +08:00
legacyCollectionImporter . ImportFromStorage ( storage ) . ContinueWith ( _ = > storage . Move ( "collection.db" , "collection.db.migrated" ) ) ;
2022-07-27 22:19:00 +08:00
}
break ;
2022-09-15 15:17:48 +08:00
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 ) ) ;
2023-02-06 02:46:38 +08:00
break ;
2023-02-07 08:16:25 +08:00
2023-02-06 02:46:38 +08:00
case 26 :
2023-02-08 13:24:06 +08:00
// Add ScoreInfo.BeatmapHash property to ensure scores correspond to the correct version of beatmap.
var scores = migration . NewRealm . All < ScoreInfo > ( ) ;
2023-02-06 02:46:38 +08:00
2023-02-08 13:24:06 +08:00
foreach ( var score in scores )
score . BeatmapHash = score . BeatmapInfo . Hash ;
2023-02-06 02:46:38 +08:00
2022-09-15 15:17:48 +08:00
break ;
2021-10-18 14:35:51 +08:00
}
2021-01-13 17:24:19 +08:00
}
2021-01-07 13:07:36 +08:00
2022-09-15 15:39:59 +08:00
private string? getRulesetShortNameFromLegacyID ( long rulesetId )
{
try
{
return new APIBeatmap . APIRuleset { OnlineID = ( int ) rulesetId } . ShortName ;
}
catch
{
return null ;
}
}
2021-11-23 15:27:28 +08:00
2022-07-04 15:27:49 +08:00
/// <summary>
/// Create a full realm backup.
/// </summary>
/// <param name="backupFilename">The filename for the backup.</param>
public void CreateBackup ( string backupFilename )
2022-01-18 13:30:41 +08:00
{
2022-07-04 15:27:49 +08:00
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 ) ;
2022-01-20 23:46:47 +08:00
2022-07-04 15:27:49 +08:00
int attempts = 10 ;
2022-01-20 23:46:47 +08:00
2023-05-30 12:04:32 +08:00
while ( true )
2022-07-04 15:27:49 +08:00
{
try
2022-01-20 23:46:47 +08:00
{
2022-07-04 15:27:49 +08:00
using ( var source = storage . GetStream ( Filename , mode : FileMode . Open ) )
2022-08-17 16:17:22 +08:00
{
// source may not exist.
if ( source = = null )
return ;
2022-08-17 17:20:47 +08:00
using ( var destination = storage . GetStream ( backupFilename , FileAccess . Write , FileMode . CreateNew ) )
source . CopyTo ( destination ) ;
2022-08-17 16:17:22 +08:00
}
2022-07-04 15:27:49 +08:00
return ;
}
catch ( IOException )
{
2023-05-30 12:04:32 +08:00
if ( attempts - - < = 0 )
throw ;
2022-07-04 15:27:49 +08:00
// file may be locked during use.
Thread . Sleep ( 500 ) ;
2022-01-20 23:46:47 +08:00
}
2022-01-18 13:30:41 +08:00
}
}
2021-09-30 22:42:40 +08:00
/// <summary>
2022-01-24 19:11:36 +08:00
/// Flush any active realm instances and block any further writes.
2021-09-30 22:42:40 +08:00
/// </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>
2022-07-02 11:35:29 +08:00
/// <param name="reason">The reason for blocking. Used for logging purposes.</param>
2021-09-30 22:42:40 +08:00
/// <returns>An <see cref="IDisposable"/> which should be disposed to end the blocking section.</returns>
2022-07-02 11:35:29 +08:00
public IDisposable BlockAllOperations ( string reason )
2021-01-22 16:28:47 +08:00
{
2022-07-02 11:35:29 +08:00
Logger . Log ( $@"Attempting to block all realm operations for {reason}." , LoggingTarget . Database ) ;
2022-06-29 19:56:01 +08:00
if ( ! ThreadSafety . IsUpdateThread )
throw new InvalidOperationException ( @ $"{nameof(BlockAllOperations)} must be called from the update thread." ) ;
2021-10-15 12:58:14 +08:00
if ( isDisposed )
2022-01-24 18:59:58 +08:00
throw new ObjectDisposedException ( nameof ( RealmAccess ) ) ;
2021-06-29 19:21:31 +08:00
2022-01-24 17:36:16 +08:00
SynchronizationContext ? syncContext = null ;
2022-01-21 21:40:18 +08:00
2021-10-01 00:32:28 +08:00
try
{
2022-01-25 12:56:47 +08:00
realmRetrievalLock . Wait ( ) ;
2021-10-01 00:32:28 +08:00
2022-06-28 15:54:53 +08:00
if ( hasInitialisedOnce )
2021-10-01 02:53:33 +08:00
{
2022-06-28 15:54:53 +08:00
syncContext = SynchronizationContext . Current ;
2022-01-18 13:30:32 +08:00
2022-06-28 15:54:53 +08:00
// 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 ;
2022-01-25 15:26:06 +08:00
}
2022-01-21 21:40:18 +08:00
2022-06-28 15:54:53 +08:00
updateRealm ? . Dispose ( ) ;
updateRealm = null ;
2021-10-01 02:53:33 +08:00
}
2022-07-02 11:35:29 +08:00
Logger . Log ( @"Lock acquired for blocking operations" , LoggingTarget . Database ) ;
2022-06-28 15:54:53 +08:00
2021-10-01 00:32:28 +08:00
const int sleep_length = 200 ;
2022-07-02 15:46:52 +08:00
int timeSpent = 0 ;
2021-01-22 16:28:47 +08:00
2022-01-18 15:05:12 +08:00
try
2021-10-01 00:32:28 +08:00
{
2022-01-18 15:05:12 +08:00
// see https://github.com/realm/realm-dotnet/discussions/2657
while ( ! Compact ( ) )
{
Thread . Sleep ( sleep_length ) ;
2022-07-02 15:46:52 +08:00
timeSpent + = sleep_length ;
2021-06-29 19:21:31 +08:00
2022-07-02 15:46:52 +08:00
if ( timeSpent > 5000 )
throw new TimeoutException ( $@"Realm compact failed after {timeSpent / sleep_length} attempts over {timeSpent / 1000} seconds" ) ;
2022-01-18 15:05:12 +08:00
}
}
2022-01-19 09:58:59 +08:00
catch ( RealmException e )
2022-01-18 15:05:12 +08:00
{
// 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 ) ;
2021-10-01 00:32:28 +08:00
}
2022-01-26 16:21:57 +08:00
2022-07-02 11:35:29 +08:00
Logger . Log ( @"Realm usage isolated via compact" , LoggingTarget . Database ) ;
2022-01-26 16:21:57 +08:00
// 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
{
2022-06-29 21:45:19 +08:00
lock ( notificationsResetMap )
{
foreach ( var action in notificationsResetMap . Values )
action ( ) ;
}
2022-01-26 16:21:57 +08:00
}
finally
{
isSendingNotificationResetEvents = false ;
}
} , null ) ;
2021-10-01 00:32:28 +08:00
}
catch
{
2022-01-24 19:52:27 +08:00
restoreOperation ( ) ;
2021-10-01 00:32:28 +08:00
throw ;
}
2022-01-24 19:52:27 +08:00
return new InvokeOnDisposal ( restoreOperation ) ;
void restoreOperation ( )
2021-09-30 22:42:40 +08:00
{
2022-07-05 15:23:10 +08:00
// 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.
2021-09-30 22:42:40 +08:00
Logger . Log ( @"Restoring realm operations." , LoggingTarget . Database ) ;
2022-01-25 12:58:14 +08:00
realmRetrievalLock . Release ( ) ;
2022-01-25 19:49:52 +08:00
2022-07-04 23:27:38 +08:00
if ( syncContext = = null ) return ;
ManualResetEventSlim updateRealmReestablished = new ManualResetEventSlim ( ) ;
2022-01-21 21:40:18 +08:00
// Post back to the update thread to revive any subscriptions.
2022-01-25 19:49:52 +08:00
// 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 )
2022-07-04 23:27:38 +08:00
{
syncContext . Send ( _ = >
{
ensureUpdateRealm ( ) ;
updateRealmReestablished . Set ( ) ;
} , null ) ;
}
2022-01-25 19:49:52 +08:00
else
2022-07-04 23:27:38 +08:00
{
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" ) ;
2022-01-24 19:52:27 +08:00
}
2021-01-15 13:26:06 +08:00
}
2021-11-05 16:01:00 +08:00
// 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 ;
2021-10-15 12:58:14 +08:00
private bool isDisposed ;
public void Dispose ( )
2021-06-28 15:14:14 +08:00
{
2022-06-27 18:20:28 +08:00
if ( ! pendingAsyncWrites . Wait ( 10000 ) )
Logger . Log ( "Realm took too long waiting on pending async writes" , level : LogLevel . Error ) ;
2022-06-28 15:54:53 +08:00
updateRealm ? . Dispose ( ) ;
2021-09-30 22:42:40 +08:00
2021-10-15 12:58:14 +08:00
if ( ! isDisposed )
2021-06-28 15:14:14 +08:00
{
2022-01-25 12:56:47 +08:00
// intentionally block realm retrieval indefinitely. this ensures that nothing can start consuming a new instance after disposal.
realmRetrievalLock . Wait ( ) ;
realmRetrievalLock . Dispose ( ) ;
2021-06-28 15:14:14 +08:00
2021-10-15 12:58:14 +08:00
isDisposed = true ;
}
2021-06-28 15:14:14 +08:00
}
2021-01-07 13:07:36 +08:00
}
}