OrleansContrib / OrleansTestKit

Unit Test Toolkit for Microsoft Orleans
http://dotnet.github.io/orleans
MIT License
78 stars 43 forks source link

Unable to test Reminders - Orleans 7.x #126

Closed jkonecki closed 1 year ago

jkonecki commented 1 year ago

I'm running into the following issue when trying to test reminders after upgrading to Orleans 7.x. I suspect a change would be needed in Orleans itself.

GrainReminderExtensions class has the following check for RuntimeContext.Current which fails inside unit test:

    private static IReminderRegistry GetReminderRegistry(IGrainContext grainContext)
    {
        if (RuntimeContext.Current is null) ThrowInvalidContext();
        return grainContext.ActivationServices.GetRequiredService<IReminderRegistry>();
    }

    private static void ThrowInvalidContext()
    {
        throw new InvalidOperationException("Attempted to access grain from a non-grain context, such as a background thread, which is invalid."
            + " Ensure that you are only accessing grain functionality from within the context of a grain.");
    }

I came up with the below workaround - reflection to the rescue ;-)

var grain = await this.Silo.CreateGrainAsync<TGrain>("grain id");

var grainContext = grain.GetType().GetProperty("GrainContext", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(grain);
var runtimeContextType = Assembly.GetAssembly(typeof(IAddressable)).GetType("Orleans.Runtime.RuntimeContext");
runtimeContextType.GetMethod("SetExecutionContext", BindingFlags.Static | BindingFlags.NonPublic, new[] { typeof(IGrainContext) }).Invoke(null, new object[] { grainContext });
Badabunga commented 1 year ago

This was the breaking change which I mentioned with the Grain Context. Although there is a an Method for that : (But I share your opinion that it needs to be fixed within Orleans it self.)

You can see it in the Reminder Tests how to use the Testkit with this restriction

https://github.com/OrleansContrib/OrleansTestKit/blob/3a7f42f36d6753e07d37352e6a6d8b07ac63963e/test/OrleansTestKit.Tests/Tests/ReminderTests.cs#L32-L53

fergusbown commented 1 year ago

If you are trying to test a method that goes to a background thread, the method above doesn't help you since the context gets reset when you transition between threads. I ended up creating these classes (terrible names!), and injecting them into the grains:

internal interface IReminderExtensions
{
    Task<IGrainReminder> RegisterOrUpdateReminder(Grain grain, string reminderName, TimeSpan dueTime, TimeSpan period);

    Task UnregisterReminder(Grain grain, IGrainReminder reminder);
}

internal class ReminderExtensions : IReminderExtensions
{
    public Task<IGrainReminder> RegisterOrUpdateReminder(Grain grain, string reminderName, TimeSpan dueTime, TimeSpan period)
        => grain.RegisterOrUpdateReminder(reminderName, dueTime, period);

    public Task UnregisterReminder(Grain grain, IGrainReminder reminder)
        => grain.UnregisterReminder(reminder);
}

internal class TestReminderExtensions : IReminderExtensions
{
    private readonly TestKitSilo silo;

    public TestReminderExtensions(TestKitSilo silo)
    {
        this.silo = silo;
    }

    public async Task<IGrainReminder> RegisterOrUpdateReminder(Grain grain, string reminderName, TimeSpan dueTime, TimeSpan period)
    {
        using (await this.silo.GetReminderActivationContext(grain))
        {
            return await grain.RegisterOrUpdateReminder(reminderName, dueTime, period);
        }
    }

    public async Task UnregisterReminder(Grain grain, IGrainReminder reminder)
    {
        using (await this.silo.GetReminderActivationContext(grain))
        {
            await grain.UnregisterReminder(reminder);
        }
    }
}
tboby commented 1 year ago

If you are trying to test a method that goes to a background thread, the method above doesn't help you since the context gets reset when you transition between threads. I ended up creating these classes (terrible names!), and injecting them into the grains:

This was really useful, thanks! I did run into one further problem: It deadlocks if you use the helpers during grain activation. I came up with this hack, as I couldn't see any way of knowing if a grain has completed activation in TestKitSilo

    /// <summary>
    /// This detects if we're in the onactivate where we don't need to swap context (which deadlocks)
    /// </summary>
    private async Task<IDisposable?> CheckIfInCreateGrain(Grain grain)
    {       
        if (new StackTrace().GetFrames().Any(frame => frame.GetMethod()?.Name?.Contains("CreateGrainAsync") == true))
        {
            return null;
        }
        return await this.silo.GetReminderActivationContext(grain);
    }

    public async Task<IGrainReminder> RegisterOrUpdateReminder(Grain grain, string reminderName, TimeSpan dueTime, TimeSpan period)
    {

        using (await CheckIfInCreateGrain(grain))
        {
            return await grain.RegisterOrUpdateReminder(reminderName, dueTime, period);
        }
    }

For future readers: You might also need to shim GetReminder(s)

cmeyertons commented 1 year ago

https://github.com/OrleansContrib/OrleansTestKit/pull/135 will address this -- setting the RuntimeContext via reflection instead of setting backing fields afterwards