Search code examples
c#asynchronousasync-awaitconcurrencythread-safety

Enforce an async method to be called lazily on demand, and called again when the previous result has expired


I am consuming an asynchronous Web API that requires an AccessToken (an immutable struct) to be passed as an argument on every API call. This AccessToken is itself obtained by calling an asynchronous Authenticate method of the same Web API.

class WebApi
{
    public Task<AccessToken> Authenticate(string username, string password);
    public Task PurchaseItem(AccessToken token, int itemId, int quantity);
    // More methods having an AccessToken parameter
}

I don't want to call the Authenticate method before calling every other method of the API, for performance reasons. I want to call it once, and then reuse the same AccessToken for multiple API calls. My problem is that the AccessToken is expiring every 15 minutes, and calling any API method with an expired AccessToken results to an AccessTokenExpiredException. I could catch this exception and then retry the faulted call, after acquiring a new AccessToken, but I would prefer to preemptively refresh the AccessToken before it has expired, again for performance reasons. My application is multithreaded, so multiple threads might try to use/refresh the same AccessToken value concurrently, and things quickly start to become very messy.

The requirements are:

  1. The Authenticate method should not be called more frequently than once every 15 minutes, even if multiple threads attempt to invoke methods of the Web API concurrently.
  2. In case an Authenticate call fails, it should be repeated the next time an AccessToken is needed. This requirement takes precedence over the previous requirement. Caching and reusing a faulted Task<AccessToken> for 15 minutes is not acceptable.
  3. The Authenticate method should be called only when an AccessToken is actually needed. Invoking it every 15 minutes with a Timer is not acceptable.
  4. An AccessToken should only be used during the next 15 minutes after its creation.
  5. The expiration mechanism should not be dependent on the system clock. A system-wise clock adjustment should not affect (elongate or shorten) the expiration period.

My question is: how could I abstract the functionality of acquiring, monitoring the expiration, and refreshing the AccessToken, in a way that satisfies the requirements, while keeping the rest of my application clean from all this complexity? I am thinking of something similar to the AsyncLazy<T> type that I found in this question: Enforce an async method to be called once, but enhanced with expiration functionality. Here is a hypothetical example of using this type (enhanced with a TimeSpan parameter):

private readonly WebApi _webApi = new WebApi();
private readonly AsyncLazy<AccessToken> _accessToken = new AsyncLazy<AccessToken>(
    () => _webApi.Authenticate("xxx", "yyy"), TimeSpan.FromMinutes(15));

async Task Purchase(int itemId, int quantity)
{
    await _webApi.PurchaseItem(await _accessToken, itemId, quantity);
}

Btw this question was inspired by a recent question, where the OP was trying to solve a similar problem in a different way.


Solution

  • Here is an implementation of an AsyncExpiringLazy<T> class, which is essentially an AsyncLazy<T> with added expiration functionality:

    /// <summary>
    /// Represents the result of an asynchronous operation that is invoked lazily
    /// on demand, and is subject to an expiration policy. Errors are not cached.
    /// Subsequent executions do not overlap. Concurrent observers receive
    /// the result of the same operation.
    /// </summary>
    public class AsyncExpiringLazy<TResult>
    {
        private readonly object _locker = new object();
        private readonly Func<Task<TResult>> _taskFactory;
        private readonly Func<TResult, TimeSpan> _expirationSelector;
        private State _state;
    
        // The mutable state is stored in a record struct for convenience.
        private record struct State(Task<TResult> Task, long ExpirationTimestamp);
    
        public AsyncExpiringLazy(Func<Task<TResult>> taskFactory,
            Func<TResult, TimeSpan> expirationSelector)
        {
            ArgumentNullException.ThrowIfNull(taskFactory);
            ArgumentNullException.ThrowIfNull(expirationSelector);
            _taskFactory = taskFactory;
            _expirationSelector = expirationSelector;
        }
    
        public AsyncExpiringLazy(Func<TResult> valueFactory,
            Func<TResult, TimeSpan> expirationSelector)
        {
            ArgumentNullException.ThrowIfNull(valueFactory);
            ArgumentNullException.ThrowIfNull(expirationSelector);
            _taskFactory = () => System.Threading.Tasks.Task.FromResult(valueFactory());
            _expirationSelector = expirationSelector;
        }
    
        private Task<TResult> GetTask()
        {
            Task<Task<TResult>> newTaskTask;
            Task<TResult> newTask;
            lock (_locker)
            {
                if (_state.Task is not null
                    && _state.ExpirationTimestamp > Environment.TickCount64)
                        return _state.Task; // The task has not expired.
    
                // Either this is the first call, or the task expired or failed.
                newTaskTask = new(_taskFactory);
                newTask = newTaskTask.Unwrap().ContinueWith(task =>
                {
                    State newState = default;
                    try
                    {
                        if (task.IsCompletedSuccessfully)
                        {
                            TimeSpan expiration = _expirationSelector(task.Result);
                            if (expiration > TimeSpan.Zero)
                                newState = new State(task, Environment.TickCount64
                                    + (long)expiration.TotalMilliseconds);
                        }
                    }
                    finally
                    {
                        // In case the task or the selector failed,
                        // or the expiration is not positive, the _state is
                        // updated to default, to trigger a retry later.
                        lock (_locker) _state = newState;
                    }
                    return task;
                }, default, TaskContinuationOptions.DenyChildAttach |
                    TaskContinuationOptions.ExecuteSynchronously,
                    TaskScheduler.Default).Unwrap();
    
                // While the task is running, the expiration is set to never.
                _state = new State(newTask, Int64.MaxValue);
            }
            newTaskTask.RunSynchronously(TaskScheduler.Default);
            return newTask;
        }
    
        public Task<TResult> Task => GetTask();
    
        public TResult Result => GetTask().GetAwaiter().GetResult();
    
        public TaskAwaiter<TResult> GetAwaiter() => GetTask().GetAwaiter();
    
        public ConfiguredTaskAwaitable<TResult> ConfigureAwait(
            bool continueOnCapturedContext)
                => GetTask().ConfigureAwait(continueOnCapturedContext);
    
        public bool ExpireImmediately()
        {
            lock (_locker)
            {
                if (_state.Task is null) return false;
                if (!_state.Task.IsCompleted) return false;
                _state = default;
                return true;
            }
        }
    }
    

    Usage example:

    _webApi = new WebApi();
    _accessToken = new AsyncExpiringLazy<AccessToken>(
        async () => await _webApi.Authenticate("xxx", "yyy"), _ => TimeSpan.FromMinutes(15));
    
    try
    {
        await _webApi.PurchaseItem(await _accessToken, itemId, quantity);
    }
    catch (AccessTokenExpiredException)
    {
        _accessToken.ExpireImmediately(); throw;
    }
    

    This implementation is a modified version of the AsyncLazy<T> class that can be found in this answer. The AsyncExpiringLazy<T> constructor accepts two delegates. The taskFactory is the asynchronous method that produces the result, and it is invoked on the calling thread (the thread that calls the await _accessToken in the example above). The expirationSelector is a selector of the expiration period, which is a TimeSpan, and takes the produced result as argument. This delegate is invoked on an unknown thread (usually on the ThreadPool), immediately after a result has been asynchronously produced.

    A constructor that accepts a synchronous valueFactory is also available.

    The ExpireImmediately method causes the immediate expiration of the previously completed task. In case a task is currently running, this method has no effect.

    This implementation propagates all the exceptions that might be thrown by the taskFactory delegate, not just the first one.

    An online demonstration of the AsyncExpiringLazy<T> class can be found here. It demonstrates the behavior of the class when used by multiple concurrent workers, and when the taskFactory fails.

    The AsyncExpiringLazy<T> class is thread-safe.