Eager refresh of values for AsyncExpiringLazy

Β· 958 words Β· 5 minutes to read

Some time ago I blogged about introducing a new library, called AsyncExpiringLazy, which can be used for managing lazy-resolved values that expire and must be refreshed - such as for example access tokens to web APIs.

Yesterday I pushed out a release 2.1.0 of the library, which features a unique new feature - built thanks to the great work of Lukasz - some new additional semantics for the way how the captured value gets refreshed.

Recap on AsyncExpiringLazy πŸ”—

The main feature of the library is represented by the AsyncExpiringLazy class. The way it works, is that it will resolve the value using the provided factory logic upon first use (hence the name “lazy”). Then, the value will expire according to the predefined expiration metadata, and upon first use after it expired, it will be refreshed - resolved via the factory again.

The downside of this is that when the factory logic is slow (e.g. due to slow I/O or external dependencies), the first use after expiration would be slower too, potentially affecting the user experience. The upside is that the renewal would only happen if/when it is really needed - as long as the expired value is no longer attempted to be accessed, no “wasteful” refresh happens. We can illustrate this with a simple example.

Consider the following code. AsyncExpiringLazy wraps a factory method that produces a random integer in the range of 0-1000, which is marked to be valid for four seconds. To simulate a slowish behavior of the factory, we are using an artificial 400 millisecond delay.

private static async Task<ExpirationMetadata<int>> RandomIntegerFactory(ExpirationMetadata<int> metadata) 
{
    var validity = DateTimeOffset.UtcNow.AddMilliseconds(4000);
    await Task.Delay(400);
    
    var random = new Random().Next(0, 1000);
    Console.WriteLine($"Refreshed the value to {random} upon retrieval");
    
    return new ExpirationMetadata<int>
    {
        Result = random,
        ValidUntil = validity
    };
}

var testLazy = new AsyncExpiringLazy<int>(RandomIntegerFactory);

As long as the value is considered valid, that is in the four second time window, the same value is reused and returned to the caller. As soon as it expires, the next access to the lazy value will re-trigger the factory method. To help visualize what is going on, we can spit out a simple console message indicating that the value was refreshed.

We can run the code in an infinite loop, that accesses the value at regular intervals, for example every second, to see the effects.

while (true)
{
    await Task.Delay(1000);
    var stopwatch = Stopwatch.StartNew();
    var val = await testLazy.Value();
    var valueRetrieval = stopwatch.ElapsedMilliseconds;
    Console.WriteLine($"Current value: {val}, retrieved in {valueRetrieval}ms.");
}

The output should be similar to:

Refreshed the value to 224 upon retrieval
Current value: 224, retrieved in 406ms.
Current value: 224, retrieved in 0ms.
Current value: 224, retrieved in 0ms.
Current value: 224, retrieved in 0ms.
Refreshed the value to 899 upon retrieval
Current value: 899, retrieved in 405ms.
Current value: 899, retrieved in 0ms.
Current value: 899, retrieved in 0ms.
Current value: 899, retrieved in 0ms.
Refreshed the value to 959 upon retrieval
Current value: 959, retrieved in 400ms.
Current value: 959, retrieved in 0ms.
Current value: 959, retrieved in 0ms.
Current value: 959, retrieved in 0ms.

Because the value expires every four seconds, and we access it every second, every fourth access is slower - because of the lazy refresh. Since we chose an arbitrary overhead of 400ms, this is tacked upon the actual value access. Depending on your use cases, this may or may not be what you want.

Introducing AsyncExpiringEager πŸ”—

Enter a new type in the library, which we called AsyncExpiringEager. In the eager version, the semantics of the refresh of the value are changed - it is no longer refreshed on first access after expiration, but silently as soon as the old one expires, using a background task. AsyncExpiringEager is disposable, and disposing of it also shuts down this background monitor task.

Just like before, we can illustrate this with an example. To better compare AsyncExpiringLazy with AsyncExpiringEager, we shall use the same RandomIntegerFactory as before.

using var testEager = new AsyncExpiringEager<int>(RandomIntegerFactory);

When invoked in a similar infinite loop, with one second intervals:

while (true)
{
    await Task.Delay(1000);
    var stopwatch = Stopwatch.StartNew();
    var val = await testEager.Value();
    var valueRetrieval = stopwatch.ElapsedMilliseconds;
    Console.WriteLine($"Current value: {val}, retrieved in {valueRetrieval}ms.");
}

The output should resemble:

Refreshed the value to 695 in the background
Current value: 695, retrieved in 404ms.
Current value: 695, retrieved in 0ms.
Current value: 695, retrieved in 0ms.
Current value: 695, retrieved in 0ms.
Refreshed the value to 774 in the background
Current value: 774, retrieved in 0ms.
Current value: 774, retrieved in 0ms.
Current value: 774, retrieved in 0ms.
Current value: 774, retrieved in 0ms.
Refreshed the value to 202 in the background
Current value: 202, retrieved in 0ms.
Current value: 202, retrieved in 0ms.
Current value: 202, retrieved in 0ms.
Current value: 202, retrieved in 0ms.

The initial population of the value shows the extra 400ms overhead - that is because we still deal with a “lazy” construct, so it gets initialized on first usage. However, the subsequent accesses are always fast, because the refresh of the value happens behind the scenes and the first access after expiration no longer suffers from the factory-induced delay.

Of course, this works this exact way because of how access patterns and refresh time align against each other. Should we shuffle them up, it might obviously happen that some accesses of the value would happen exactly at the moment as the value is being refreshed and some slow down may be observed then. But in principle, this model provides an alternative to the default use case, giving more flexibility to you as the person writing code. In particular, AsyncExpiringEager works well for scenarios where the value is expensive to compute.

You get it from Nuget as version 2.1.0 now.

About


Hi! I'm Filip W., a software architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP