mirror of
https://github.com/ppy/osu.git
synced 2024-12-13 08:32:57 +08:00
Merge pull request #16547 from peppy/realm-stable-subscriptions
Fix realm subscriptions getting lost after a context recycle
This commit is contained in:
commit
bb54ad9ad8
@ -46,7 +46,7 @@ namespace osu.Game.Tests.Database
|
||||
{
|
||||
bool callbackRan = false;
|
||||
|
||||
realmFactory.Run(realm =>
|
||||
realmFactory.RegisterCustomSubscription(realm =>
|
||||
{
|
||||
var subscription = realm.All<BeatmapInfo>().QueryAsyncWithNotifications((sender, changes, error) =>
|
||||
{
|
||||
@ -60,6 +60,7 @@ namespace osu.Game.Tests.Database
|
||||
realmFactory.Run(r => r.Refresh());
|
||||
|
||||
subscription?.Dispose();
|
||||
return null;
|
||||
});
|
||||
|
||||
Assert.IsTrue(callbackRan);
|
||||
|
@ -239,7 +239,7 @@ namespace osu.Game.Tests.Database
|
||||
{
|
||||
int changesTriggered = 0;
|
||||
|
||||
realmFactory.Run(outerRealm =>
|
||||
realmFactory.RegisterCustomSubscription(outerRealm =>
|
||||
{
|
||||
outerRealm.All<BeatmapInfo>().QueryAsyncWithNotifications(gotChange);
|
||||
ILive<BeatmapInfo>? liveBeatmap = null;
|
||||
@ -282,6 +282,8 @@ namespace osu.Game.Tests.Database
|
||||
r.Remove(resolved);
|
||||
});
|
||||
});
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
void gotChange(IRealmCollection<BeatmapInfo> sender, ChangeSet changes, Exception error)
|
||||
|
138
osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs
Normal file
138
osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs
Normal file
@ -0,0 +1,138 @@
|
||||
// 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.Linq;
|
||||
using NUnit.Framework;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Tests.Resources;
|
||||
using Realms;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace osu.Game.Tests.Database
|
||||
{
|
||||
[TestFixture]
|
||||
public class RealmSubscriptionRegistrationTests : RealmTest
|
||||
{
|
||||
[Test]
|
||||
public void TestSubscriptionWithContextLoss()
|
||||
{
|
||||
IEnumerable<BeatmapSetInfo>? resolvedItems = null;
|
||||
ChangeSet? lastChanges = null;
|
||||
|
||||
RunTestWithRealm((realmFactory, _) =>
|
||||
{
|
||||
realmFactory.Write(realm => realm.Add(TestResources.CreateTestBeatmapSetInfo()));
|
||||
|
||||
var registration = realmFactory.RegisterForNotifications(realm => realm.All<BeatmapSetInfo>(), onChanged);
|
||||
|
||||
testEventsArriving(true);
|
||||
|
||||
// All normal until here.
|
||||
// Now let's yank the main realm context.
|
||||
resolvedItems = null;
|
||||
lastChanges = null;
|
||||
|
||||
using (realmFactory.BlockAllOperations())
|
||||
Assert.That(resolvedItems, Is.Empty);
|
||||
|
||||
realmFactory.Write(realm => realm.Add(TestResources.CreateTestBeatmapSetInfo()));
|
||||
|
||||
testEventsArriving(true);
|
||||
|
||||
// Now let's try unsubscribing.
|
||||
resolvedItems = null;
|
||||
lastChanges = null;
|
||||
|
||||
registration.Dispose();
|
||||
|
||||
realmFactory.Write(realm => realm.Add(TestResources.CreateTestBeatmapSetInfo()));
|
||||
|
||||
testEventsArriving(false);
|
||||
|
||||
// And make sure even after another context loss we don't get firings.
|
||||
using (realmFactory.BlockAllOperations())
|
||||
Assert.That(resolvedItems, Is.Null);
|
||||
|
||||
realmFactory.Write(realm => realm.Add(TestResources.CreateTestBeatmapSetInfo()));
|
||||
|
||||
testEventsArriving(false);
|
||||
|
||||
void testEventsArriving(bool shouldArrive)
|
||||
{
|
||||
realmFactory.Run(realm => realm.Refresh());
|
||||
|
||||
if (shouldArrive)
|
||||
Assert.That(resolvedItems, Has.One.Items);
|
||||
else
|
||||
Assert.That(resolvedItems, Is.Null);
|
||||
|
||||
realmFactory.Write(realm =>
|
||||
{
|
||||
realm.RemoveAll<BeatmapSetInfo>();
|
||||
realm.RemoveAll<RulesetInfo>();
|
||||
});
|
||||
|
||||
realmFactory.Run(realm => realm.Refresh());
|
||||
|
||||
if (shouldArrive)
|
||||
Assert.That(lastChanges?.DeletedIndices, Has.One.Items);
|
||||
else
|
||||
Assert.That(lastChanges, Is.Null);
|
||||
}
|
||||
});
|
||||
|
||||
void onChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes, Exception error)
|
||||
{
|
||||
if (changes == null)
|
||||
resolvedItems = sender;
|
||||
|
||||
lastChanges = changes;
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestCustomRegisterWithContextLoss()
|
||||
{
|
||||
RunTestWithRealm((realmFactory, _) =>
|
||||
{
|
||||
BeatmapSetInfo? beatmapSetInfo = null;
|
||||
|
||||
realmFactory.Write(realm => realm.Add(TestResources.CreateTestBeatmapSetInfo()));
|
||||
|
||||
var subscription = realmFactory.RegisterCustomSubscription(realm =>
|
||||
{
|
||||
beatmapSetInfo = realm.All<BeatmapSetInfo>().First();
|
||||
|
||||
return new InvokeOnDisposal(() => beatmapSetInfo = null);
|
||||
});
|
||||
|
||||
Assert.That(beatmapSetInfo, Is.Not.Null);
|
||||
|
||||
using (realmFactory.BlockAllOperations())
|
||||
{
|
||||
// custom disposal action fired when context lost.
|
||||
Assert.That(beatmapSetInfo, Is.Null);
|
||||
}
|
||||
|
||||
// re-registration after context restore.
|
||||
realmFactory.Run(realm => realm.Refresh());
|
||||
Assert.That(beatmapSetInfo, Is.Not.Null);
|
||||
|
||||
subscription.Dispose();
|
||||
|
||||
Assert.That(beatmapSetInfo, Is.Null);
|
||||
|
||||
using (realmFactory.BlockAllOperations())
|
||||
Assert.That(beatmapSetInfo, Is.Null);
|
||||
|
||||
realmFactory.Run(realm => realm.Refresh());
|
||||
Assert.That(beatmapSetInfo, Is.Null);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -80,7 +80,10 @@ namespace osu.Game.Tests.Resources
|
||||
public static BeatmapSetInfo CreateTestBeatmapSetInfo(int? difficultyCount = null, RulesetInfo[] rulesets = null)
|
||||
{
|
||||
int j = 0;
|
||||
RulesetInfo getRuleset() => rulesets?[j++ % rulesets.Length] ?? new OsuRuleset().RulesetInfo;
|
||||
|
||||
rulesets ??= new[] { new OsuRuleset().RulesetInfo };
|
||||
|
||||
RulesetInfo getRuleset() => rulesets?[j++ % rulesets.Length];
|
||||
|
||||
int setId = Interlocked.Increment(ref importId);
|
||||
|
||||
|
46
osu.Game/Database/EmptyRealmSet.cs
Normal file
46
osu.Game/Database/EmptyRealmSet.cs
Normal file
@ -0,0 +1,46 @@
|
||||
// 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;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using Realms;
|
||||
using Realms.Schema;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
public class EmptyRealmSet<T> : IRealmCollection<T>
|
||||
{
|
||||
private IList<T> emptySet => Array.Empty<T>();
|
||||
|
||||
public IEnumerator<T> GetEnumerator() => emptySet.GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => emptySet.GetEnumerator();
|
||||
public int Count => emptySet.Count;
|
||||
public T this[int index] => emptySet[index];
|
||||
public int IndexOf(object item) => emptySet.IndexOf((T)item);
|
||||
public bool Contains(object item) => emptySet.Contains((T)item);
|
||||
|
||||
public event NotifyCollectionChangedEventHandler? CollectionChanged
|
||||
{
|
||||
add => throw new NotImplementedException();
|
||||
remove => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged
|
||||
{
|
||||
add => throw new NotImplementedException();
|
||||
remove => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public IRealmCollection<T> Freeze() => throw new NotImplementedException();
|
||||
public IDisposable SubscribeForNotifications(NotificationCallbackDelegate<T> callback) => throw new NotImplementedException();
|
||||
public bool IsValid => throw new NotImplementedException();
|
||||
public Realm Realm => throw new NotImplementedException();
|
||||
public ObjectSchema ObjectSchema => throw new NotImplementedException();
|
||||
public bool IsFrozen => throw new NotImplementedException();
|
||||
}
|
||||
}
|
@ -2,6 +2,8 @@
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
@ -61,33 +63,60 @@ namespace osu.Game.Database
|
||||
|
||||
private readonly ThreadLocal<bool> currentThreadCanCreateContexts = 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 a realm context goes away, 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> contexts_created = GlobalStatistics.Get<int>(@"Realm", @"Contexts (Created)");
|
||||
|
||||
private readonly object contextLock = new object();
|
||||
|
||||
private Realm? context;
|
||||
|
||||
public Realm Context
|
||||
public Realm Context => ensureUpdateContext();
|
||||
|
||||
private Realm ensureUpdateContext()
|
||||
{
|
||||
get
|
||||
if (!ThreadSafety.IsUpdateThread)
|
||||
throw new InvalidOperationException(@$"Use {nameof(createContext)} when performing realm operations from a non-update thread");
|
||||
|
||||
lock (contextLock)
|
||||
{
|
||||
if (!ThreadSafety.IsUpdateThread)
|
||||
throw new InvalidOperationException(@$"Use {nameof(Run)}/{nameof(Write)} when performing realm operations from a non-update thread");
|
||||
|
||||
lock (contextLock)
|
||||
if (context == null)
|
||||
{
|
||||
if (context == null)
|
||||
{
|
||||
context = createContext();
|
||||
Logger.Log(@$"Opened realm ""{context.Config.DatabasePath}"" at version {context.Config.SchemaVersion}");
|
||||
}
|
||||
context = createContext();
|
||||
Logger.Log(@$"Opened realm ""{context.Config.DatabasePath}"" at version {context.Config.SchemaVersion}");
|
||||
|
||||
// creating a context will ensure our schema is up-to-date and migrated.
|
||||
return context;
|
||||
// Resubscribe any subscriptions
|
||||
foreach (var action in customSubscriptionsResetMap.Keys)
|
||||
registerSubscription(action);
|
||||
}
|
||||
|
||||
Debug.Assert(context != null);
|
||||
|
||||
// creating a context will ensure our schema is up-to-date and migrated.
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
||||
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 of a realm context factory.
|
||||
/// </summary>
|
||||
@ -222,6 +251,117 @@ namespace osu.Game.Database
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 context loss.
|
||||
/// When this happens, callback events will be automatically fired:
|
||||
/// - On context loss, a callback with an empty collection and <c>null</c> <see cref="ChangeSet"/> will be invoked.
|
||||
/// - On context revival, 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
|
||||
{
|
||||
if (!ThreadSafety.IsUpdateThread)
|
||||
throw new InvalidOperationException(@$"{nameof(RegisterForNotifications)} must be called from the update thread.");
|
||||
|
||||
lock (contextLock)
|
||||
{
|
||||
Func<Realm, IDisposable?> action = realm => query(realm).QueryAsyncWithNotifications(callback);
|
||||
|
||||
// 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));
|
||||
return RegisterCustomSubscription(action);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run work on realm that will be run every time the update thread realm context 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 (!ThreadSafety.IsUpdateThread)
|
||||
throw new InvalidOperationException(@$"{nameof(RegisterForNotifications)} must be called from the update thread.");
|
||||
|
||||
var syncContext = SynchronizationContext.Current;
|
||||
|
||||
registerSubscription(action);
|
||||
|
||||
// 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)
|
||||
unsubscribe();
|
||||
else
|
||||
syncContext.Post(_ => unsubscribe(), null);
|
||||
|
||||
void unsubscribe()
|
||||
{
|
||||
lock (contextLock)
|
||||
{
|
||||
if (customSubscriptionsResetMap.TryGetValue(action, out var unsubscriptionAction))
|
||||
{
|
||||
unsubscriptionAction?.Dispose();
|
||||
customSubscriptionsResetMap.Remove(action);
|
||||
notificationsResetMap.Remove(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void registerSubscription(Func<Realm, IDisposable?> action)
|
||||
{
|
||||
Debug.Assert(ThreadSafety.IsUpdateThread);
|
||||
|
||||
lock (contextLock)
|
||||
{
|
||||
// Retrieve context outside of flag update to ensure that the context is constructed,
|
||||
// as attempting to access it inside the subscription if it's not constructed would lead to
|
||||
// cyclic invocations of the subscription callback.
|
||||
var realm = Context;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregister all subscriptions when the realm context is to be recycled.
|
||||
/// Subscriptions will still remain and will be re-subscribed when the realm context returns.
|
||||
/// </summary>
|
||||
private void unregisterAllSubscriptions()
|
||||
{
|
||||
lock (contextLock)
|
||||
{
|
||||
foreach (var action in notificationsResetMap.Values)
|
||||
action();
|
||||
|
||||
foreach (var action in customSubscriptionsResetMap)
|
||||
{
|
||||
action.Value?.Dispose();
|
||||
customSubscriptionsResetMap[action.Key] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Realm createContext()
|
||||
{
|
||||
if (isDisposed)
|
||||
@ -454,14 +594,29 @@ namespace osu.Game.Database
|
||||
if (isDisposed)
|
||||
throw new ObjectDisposedException(nameof(RealmContextFactory));
|
||||
|
||||
SynchronizationContext? syncContext = null;
|
||||
|
||||
try
|
||||
{
|
||||
contextCreationLock.Wait();
|
||||
|
||||
lock (contextLock)
|
||||
{
|
||||
if (!ThreadSafety.IsUpdateThread && context != null)
|
||||
throw new InvalidOperationException(@$"{nameof(BlockAllOperations)} must be called from the update thread.");
|
||||
if (context == null)
|
||||
{
|
||||
// null context means the update thread has not yet retrieved its context.
|
||||
// we don't need to worry about reviving the update context in this case, so don't bother with the SynchronizationContext.
|
||||
Debug.Assert(!ThreadSafety.IsUpdateThread);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!ThreadSafety.IsUpdateThread)
|
||||
throw new InvalidOperationException(@$"{nameof(BlockAllOperations)} must be called from the update thread.");
|
||||
|
||||
syncContext = SynchronizationContext.Current;
|
||||
}
|
||||
|
||||
unregisterAllSubscriptions();
|
||||
|
||||
Logger.Log(@"Blocking realm operations.", LoggingTarget.Database);
|
||||
|
||||
@ -501,6 +656,9 @@ namespace osu.Game.Database
|
||||
{
|
||||
factory.contextCreationLock.Release();
|
||||
Logger.Log(@"Restoring realm operations.", LoggingTarget.Database);
|
||||
|
||||
// Post back to the update thread to revive any subscriptions.
|
||||
syncContext?.Post(_ => ensureUpdateContext(), null);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -7,7 +7,6 @@ using System.Linq;
|
||||
using System.Runtime.Serialization;
|
||||
using AutoMapper;
|
||||
using AutoMapper.Internal;
|
||||
using osu.Framework.Development;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Input.Bindings;
|
||||
using osu.Game.Models;
|
||||
@ -272,9 +271,8 @@ namespace osu.Game.Database
|
||||
public static IDisposable? QueryAsyncWithNotifications<T>(this IRealmCollection<T> collection, NotificationCallbackDelegate<T> callback)
|
||||
where T : RealmObjectBase
|
||||
{
|
||||
// Subscriptions can only work on the main thread.
|
||||
if (!ThreadSafety.IsUpdateThread)
|
||||
throw new InvalidOperationException("Cannot subscribe for realm notifications from a non-update thread.");
|
||||
if (!RealmContextFactory.CurrentThreadSubscriptionsAllowed)
|
||||
throw new InvalidOperationException($"Make sure to call {nameof(RealmContextFactory)}.{nameof(RealmContextFactory.RegisterForNotifications)}");
|
||||
|
||||
return collection.SubscribeForNotifications(callback);
|
||||
}
|
||||
|
@ -23,7 +23,6 @@ namespace osu.Game.Input.Bindings
|
||||
private readonly int? variant;
|
||||
|
||||
private IDisposable realmSubscription;
|
||||
private IQueryable<RealmKeyBinding> realmKeyBindings;
|
||||
|
||||
[Resolved]
|
||||
private RealmContextFactory realmFactory { get; set; }
|
||||
@ -47,22 +46,21 @@ namespace osu.Game.Input.Bindings
|
||||
throw new InvalidOperationException($"{nameof(variant)} can not be null when a non-null {nameof(ruleset)} is provided.");
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
private IQueryable<RealmKeyBinding> queryRealmKeyBindings()
|
||||
{
|
||||
string rulesetName = ruleset?.ShortName;
|
||||
return realmFactory.Context.All<RealmKeyBinding>()
|
||||
.Where(b => b.RulesetName == rulesetName && b.Variant == variant);
|
||||
}
|
||||
|
||||
realmKeyBindings = realmFactory.Context.All<RealmKeyBinding>()
|
||||
.Where(b => b.RulesetName == rulesetName && b.Variant == variant);
|
||||
|
||||
realmSubscription = realmKeyBindings
|
||||
.QueryAsyncWithNotifications((sender, changes, error) =>
|
||||
{
|
||||
// first subscription ignored as we are handling this in LoadComplete.
|
||||
if (changes == null)
|
||||
return;
|
||||
|
||||
ReloadMappings();
|
||||
});
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
realmSubscription = realmFactory.RegisterForNotifications(realm => queryRealmKeyBindings(), (sender, changes, error) =>
|
||||
{
|
||||
// The first fire of this is a bit redundant as this is being called in base.LoadComplete,
|
||||
// but this is safest in case the subscription is restored after a context recycle.
|
||||
ReloadMappings();
|
||||
});
|
||||
|
||||
base.LoadComplete();
|
||||
}
|
||||
@ -78,11 +76,11 @@ namespace osu.Game.Input.Bindings
|
||||
{
|
||||
var defaults = DefaultKeyBindings.ToList();
|
||||
|
||||
List<RealmKeyBinding> newBindings = realmKeyBindings.Detach()
|
||||
// this ordering is important to ensure that we read entries from the database in the order
|
||||
// enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise
|
||||
// have been eaten by the music controller due to query order.
|
||||
.OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.ActionInt)).ToList();
|
||||
List<RealmKeyBinding> newBindings = queryRealmKeyBindings().Detach()
|
||||
// this ordering is important to ensure that we read entries from the database in the order
|
||||
// enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise
|
||||
// have been eaten by the music controller due to query order.
|
||||
.OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.ActionInt)).ToList();
|
||||
|
||||
// In the case no bindings were found in the database, presume this usage is for a non-databased ruleset.
|
||||
// This actually should never be required and can be removed if it is ever deemed to cause a problem.
|
||||
|
@ -42,7 +42,7 @@ namespace osu.Game.Online
|
||||
// Used to interact with manager classes that don't support interface types. Will eventually be replaced.
|
||||
var beatmapSetInfo = new BeatmapSetInfo { OnlineID = TrackedItem.OnlineID };
|
||||
|
||||
realmSubscription = realmContextFactory.Context.All<BeatmapSetInfo>().Where(s => s.OnlineID == TrackedItem.OnlineID && !s.DeletePending).QueryAsyncWithNotifications((items, changes, ___) =>
|
||||
realmSubscription = realmContextFactory.RegisterForNotifications(realm => realm.All<BeatmapSetInfo>().Where(s => s.OnlineID == TrackedItem.OnlineID && !s.DeletePending), (items, changes, ___) =>
|
||||
{
|
||||
if (items.Any())
|
||||
Schedule(() => UpdateState(DownloadState.LocallyAvailable));
|
||||
|
@ -78,7 +78,7 @@ namespace osu.Game.Online.Rooms
|
||||
|
||||
// handles changes to hash that didn't occur from the import process (ie. a user editing the beatmap in the editor, somehow).
|
||||
realmSubscription?.Dispose();
|
||||
realmSubscription = filteredBeatmaps().QueryAsyncWithNotifications((items, changes, ___) =>
|
||||
realmSubscription = realmContextFactory.RegisterForNotifications(realm => filteredBeatmaps(), (items, changes, ___) =>
|
||||
{
|
||||
if (changes == null)
|
||||
return;
|
||||
|
@ -47,7 +47,7 @@ namespace osu.Game.Online
|
||||
Downloader.DownloadBegan += downloadBegan;
|
||||
Downloader.DownloadFailed += downloadFailed;
|
||||
|
||||
realmSubscription = realmContextFactory.Context.All<ScoreInfo>().Where(s => ((s.OnlineID > 0 && s.OnlineID == TrackedItem.OnlineID) || s.Hash == TrackedItem.Hash) && !s.DeletePending).QueryAsyncWithNotifications((items, changes, ___) =>
|
||||
realmSubscription = realmContextFactory.RegisterForNotifications(realm => realm.All<ScoreInfo>().Where(s => ((s.OnlineID > 0 && s.OnlineID == TrackedItem.OnlineID) || s.Hash == TrackedItem.Hash) && !s.DeletePending), (items, changes, ___) =>
|
||||
{
|
||||
if (items.Any())
|
||||
Schedule(() => UpdateState(DownloadState.LocallyAvailable));
|
||||
|
@ -80,26 +80,32 @@ namespace osu.Game.Overlays
|
||||
mods.BindValueChanged(_ => ResetTrackAdjustments(), true);
|
||||
}
|
||||
|
||||
private IQueryable<BeatmapSetInfo> queryRealmBeatmapSets() =>
|
||||
realmFactory.Context
|
||||
.All<BeatmapSetInfo>()
|
||||
.Where(s => !s.DeletePending);
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
var availableBeatmaps = realmFactory.Context
|
||||
.All<BeatmapSetInfo>()
|
||||
.Where(s => !s.DeletePending);
|
||||
|
||||
// ensure we're ready before completing async load.
|
||||
// probably not a good way of handling this (as there is a period we aren't watching for changes until the realm subscription finishes up.
|
||||
foreach (var s in availableBeatmaps)
|
||||
beatmapSets.Add(s);
|
||||
foreach (var s in queryRealmBeatmapSets())
|
||||
beatmapSets.Add(s.Detach());
|
||||
|
||||
beatmapSubscription = availableBeatmaps.QueryAsyncWithNotifications(beatmapsChanged);
|
||||
beatmapSubscription = realmFactory.RegisterForNotifications(realm => queryRealmBeatmapSets(), beatmapsChanged);
|
||||
}
|
||||
|
||||
private void beatmapsChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet changes, Exception error)
|
||||
{
|
||||
if (changes == null)
|
||||
{
|
||||
beatmapSets.Clear();
|
||||
foreach (var s in sender)
|
||||
beatmapSets.Add(s.Detach());
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (int i in changes.InsertedIndices)
|
||||
beatmapSets.Insert(i, sender[i].Detach());
|
||||
|
@ -1,9 +1,13 @@
|
||||
// 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.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Localisation;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Localisation;
|
||||
@ -17,6 +21,9 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(GameHost host, RealmContextFactory realmFactory)
|
||||
{
|
||||
SettingsButton blockAction;
|
||||
SettingsButton unblockAction;
|
||||
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new SettingsButton
|
||||
@ -35,6 +42,51 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings
|
||||
}
|
||||
}
|
||||
},
|
||||
blockAction = new SettingsButton
|
||||
{
|
||||
Text = "Block realm",
|
||||
},
|
||||
unblockAction = new SettingsButton
|
||||
{
|
||||
Text = "Unblock realm",
|
||||
},
|
||||
};
|
||||
|
||||
blockAction.Action = () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var token = realmFactory.BlockAllOperations();
|
||||
|
||||
blockAction.Enabled.Value = false;
|
||||
|
||||
// As a safety measure, unblock after 10 seconds.
|
||||
// This is to handle the case where a dev may block, but then something on the update thread
|
||||
// accesses realm and blocks for eternity.
|
||||
Task.Factory.StartNew(() =>
|
||||
{
|
||||
Thread.Sleep(10000);
|
||||
unblock();
|
||||
});
|
||||
|
||||
unblockAction.Action = unblock;
|
||||
|
||||
void unblock()
|
||||
{
|
||||
token?.Dispose();
|
||||
token = null;
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
blockAction.Enabled.Value = true;
|
||||
unblockAction.Action = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.Error(e, "Blocking realm failed");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -50,7 +50,12 @@ namespace osu.Game.Overlays.Settings.Sections
|
||||
private RealmContextFactory realmFactory { get; set; }
|
||||
|
||||
private IDisposable realmSubscription;
|
||||
private IQueryable<SkinInfo> realmSkins;
|
||||
|
||||
private IQueryable<SkinInfo> queryRealmSkins() =>
|
||||
realmFactory.Context.All<SkinInfo>()
|
||||
.Where(s => !s.DeletePending)
|
||||
.OrderByDescending(s => s.Protected) // protected skins should be at the top.
|
||||
.ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[BackgroundDependencyLoader(permitNulls: true)]
|
||||
private void load(OsuConfigManager config, [CanBeNull] SkinEditorOverlay skinEditor)
|
||||
@ -78,20 +83,12 @@ namespace osu.Game.Overlays.Settings.Sections
|
||||
|
||||
skinDropdown.Current = dropdownBindable;
|
||||
|
||||
realmSkins = realmFactory.Context.All<SkinInfo>()
|
||||
.Where(s => !s.DeletePending)
|
||||
.OrderByDescending(s => s.Protected) // protected skins should be at the top.
|
||||
.ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
realmSubscription = realmSkins
|
||||
.QueryAsyncWithNotifications((sender, changes, error) =>
|
||||
{
|
||||
if (changes == null)
|
||||
return;
|
||||
|
||||
// Eventually this should be handling the individual changes rather than refreshing the whole dropdown.
|
||||
updateItems();
|
||||
});
|
||||
realmSubscription = realmFactory.RegisterForNotifications(realm => queryRealmSkins(), (sender, changes, error) =>
|
||||
{
|
||||
// The first fire of this is a bit redundant due to the call below,
|
||||
// but this is safest in case the subscription is restored after a context recycle.
|
||||
updateItems();
|
||||
});
|
||||
|
||||
updateItems();
|
||||
|
||||
@ -131,9 +128,9 @@ namespace osu.Game.Overlays.Settings.Sections
|
||||
|
||||
private void updateItems()
|
||||
{
|
||||
int protectedCount = realmSkins.Count(s => s.Protected);
|
||||
int protectedCount = queryRealmSkins().Count(s => s.Protected);
|
||||
|
||||
skinItems = realmSkins.ToLive(realmFactory);
|
||||
skinItems = queryRealmSkins().ToLive(realmFactory);
|
||||
|
||||
skinItems.Insert(protectedCount, random_skin_info);
|
||||
|
||||
|
@ -190,13 +190,13 @@ namespace osu.Game.Screens.Select
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
subscriptionSets = getBeatmapSets(realmFactory.Context).QueryAsyncWithNotifications(beatmapSetsChanged);
|
||||
subscriptionBeatmaps = realmFactory.Context.All<BeatmapInfo>().Where(b => !b.Hidden).QueryAsyncWithNotifications(beatmapsChanged);
|
||||
subscriptionSets = realmFactory.RegisterForNotifications(getBeatmapSets, beatmapSetsChanged);
|
||||
subscriptionBeatmaps = realmFactory.RegisterForNotifications(realm => realm.All<BeatmapInfo>().Where(b => !b.Hidden), beatmapsChanged);
|
||||
|
||||
// Can't use main subscriptions because we can't lookup deleted indices.
|
||||
// https://github.com/realm/realm-dotnet/discussions/2634#discussioncomment-1605595.
|
||||
subscriptionDeletedSets = realmFactory.Context.All<BeatmapSetInfo>().Where(s => s.DeletePending && !s.Protected).QueryAsyncWithNotifications(deletedBeatmapSetsChanged);
|
||||
subscriptionHiddenBeatmaps = realmFactory.Context.All<BeatmapInfo>().Where(b => b.Hidden).QueryAsyncWithNotifications(beatmapsChanged);
|
||||
subscriptionDeletedSets = realmFactory.RegisterForNotifications(realm => realm.All<BeatmapSetInfo>().Where(s => s.DeletePending && !s.Protected), deletedBeatmapSetsChanged);
|
||||
subscriptionHiddenBeatmaps = realmFactory.RegisterForNotifications(realm => realm.All<BeatmapInfo>().Where(b => b.Hidden), beatmapsChanged);
|
||||
}
|
||||
|
||||
private void deletedBeatmapSetsChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet changes, Exception error)
|
||||
@ -274,7 +274,7 @@ namespace osu.Game.Screens.Select
|
||||
}
|
||||
}
|
||||
|
||||
private IRealmCollection<BeatmapSetInfo> getBeatmapSets(Realm realm) => realm.All<BeatmapSetInfo>().Where(s => !s.DeletePending && !s.Protected).AsRealmCollection();
|
||||
private IQueryable<BeatmapSetInfo> getBeatmapSets(Realm realm) => realm.All<BeatmapSetInfo>().Where(s => !s.DeletePending && !s.Protected);
|
||||
|
||||
public void RemoveBeatmapSet(BeatmapSetInfo beatmapSet) =>
|
||||
removeBeatmapSet(beatmapSet.ID);
|
||||
@ -552,10 +552,11 @@ namespace osu.Game.Screens.Select
|
||||
|
||||
private void signalBeatmapsLoaded()
|
||||
{
|
||||
Debug.Assert(BeatmapSetsLoaded == false);
|
||||
|
||||
BeatmapSetsChanged?.Invoke();
|
||||
BeatmapSetsLoaded = true;
|
||||
if (!BeatmapSetsLoaded)
|
||||
{
|
||||
BeatmapSetsChanged?.Invoke();
|
||||
BeatmapSetsLoaded = true;
|
||||
}
|
||||
|
||||
itemsCache.Invalidate();
|
||||
}
|
||||
|
@ -48,18 +48,19 @@ namespace osu.Game.Screens.Select.Carousel
|
||||
ruleset.BindValueChanged(_ =>
|
||||
{
|
||||
scoreSubscription?.Dispose();
|
||||
scoreSubscription = realmFactory.Context.All<ScoreInfo>()
|
||||
.Filter($"{nameof(ScoreInfo.User)}.{nameof(RealmUser.OnlineID)} == $0"
|
||||
+ $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $1"
|
||||
+ $" && {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $2"
|
||||
+ $" && {nameof(ScoreInfo.DeletePending)} == false", api.LocalUser.Value.Id, beatmapInfo.ID, ruleset.Value.ShortName)
|
||||
.OrderByDescending(s => s.TotalScore)
|
||||
.QueryAsyncWithNotifications((items, changes, ___) =>
|
||||
{
|
||||
Rank = items.FirstOrDefault()?.Rank;
|
||||
// Required since presence is changed via IsPresent override
|
||||
Invalidate(Invalidation.Presence);
|
||||
});
|
||||
scoreSubscription = realmFactory.RegisterForNotifications(realm =>
|
||||
realm.All<ScoreInfo>()
|
||||
.Filter($"{nameof(ScoreInfo.User)}.{nameof(RealmUser.OnlineID)} == $0"
|
||||
+ $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $1"
|
||||
+ $" && {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $2"
|
||||
+ $" && {nameof(ScoreInfo.DeletePending)} == false", api.LocalUser.Value.Id, beatmapInfo.ID, ruleset.Value.ShortName)
|
||||
.OrderByDescending(s => s.TotalScore),
|
||||
(items, changes, ___) =>
|
||||
{
|
||||
Rank = items.FirstOrDefault()?.Rank;
|
||||
// Required since presence is changed via IsPresent override
|
||||
Invalidate(Invalidation.Presence);
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
|
||||
|
@ -44,9 +44,13 @@ namespace osu.Game.Screens.Select.Leaderboards
|
||||
beatmapInfo = value;
|
||||
Scores = null;
|
||||
|
||||
UpdateScores();
|
||||
if (IsLoaded)
|
||||
refreshRealmSubscription();
|
||||
if (IsOnlineScope)
|
||||
UpdateScores();
|
||||
else
|
||||
{
|
||||
if (IsLoaded)
|
||||
refreshRealmSubscription();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,15 +113,14 @@ namespace osu.Game.Screens.Select.Leaderboards
|
||||
if (beatmapInfo == null)
|
||||
return;
|
||||
|
||||
scoreSubscription = realmFactory.Context.All<ScoreInfo>()
|
||||
.Filter($"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} = $0", beatmapInfo.ID)
|
||||
.QueryAsyncWithNotifications((_, changes, ___) =>
|
||||
{
|
||||
if (changes == null)
|
||||
return;
|
||||
|
||||
RefreshScores();
|
||||
});
|
||||
scoreSubscription = realmFactory.RegisterForNotifications(realm =>
|
||||
realm.All<ScoreInfo>()
|
||||
.Filter($"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} = $0", beatmapInfo.ID),
|
||||
(_, changes, ___) =>
|
||||
{
|
||||
if (!IsOnlineScope)
|
||||
RefreshScores();
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Reset()
|
||||
|
@ -17,6 +17,7 @@ using osu.Game.Online.Spectator;
|
||||
using osu.Game.Replays;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Scoring;
|
||||
using Realms;
|
||||
|
||||
namespace osu.Game.Screens.Spectate
|
||||
{
|
||||
@ -79,23 +80,21 @@ namespace osu.Game.Screens.Spectate
|
||||
playingUserStates.BindTo(spectatorClient.PlayingUserStates);
|
||||
playingUserStates.BindCollectionChanged(onPlayingUserStatesChanged, true);
|
||||
|
||||
realmSubscription = realmContextFactory.Context
|
||||
.All<BeatmapSetInfo>()
|
||||
.Where(s => !s.DeletePending)
|
||||
.QueryAsyncWithNotifications((items, changes, ___) =>
|
||||
{
|
||||
if (changes?.InsertedIndices == null)
|
||||
return;
|
||||
|
||||
foreach (int c in changes.InsertedIndices)
|
||||
beatmapUpdated(items[c]);
|
||||
});
|
||||
realmSubscription = realmContextFactory.RegisterForNotifications(
|
||||
realm => realm.All<BeatmapSetInfo>().Where(s => !s.DeletePending), beatmapsChanged);
|
||||
|
||||
foreach ((int id, var _) in userMap)
|
||||
spectatorClient.WatchUser(id);
|
||||
}));
|
||||
}
|
||||
|
||||
private void beatmapsChanged(IRealmCollection<BeatmapSetInfo> items, ChangeSet changes, Exception ___)
|
||||
{
|
||||
if (changes?.InsertedIndices == null) return;
|
||||
|
||||
foreach (int c in changes.InsertedIndices) beatmapUpdated(items[c]);
|
||||
}
|
||||
|
||||
private void beatmapUpdated(BeatmapSetInfo beatmapSet)
|
||||
{
|
||||
foreach ((int userId, _) in userMap)
|
||||
|
Loading…
Reference in New Issue
Block a user