Search code examples
asp.net-corethread-safetymiddlewareasp.net-core-2.2

(ASP.NET Core middleware) How to efficiently ensure thread safety for code being executed in parallel for the same client/user?


I have a simple ASP.NET Core 2.2 Web API that uses Windows Authentication and requires the following:

  • if a user is not yet in the database, insert it on first access
  • if a user has not accessed the application in the last X hours, increment the access count for that specific user

Currently, I have written a quick and dirty solution for this:

/// <inheritdoc/>
public ValidationResult<bool> EnsureCurrentUserIsRegistered()
{
    var ret = new ValidationResult<bool> { Payload = false };
    string username = GetHttpContextUserName();
    if (string.IsNullOrWhiteSpace(username))
        return new ValidationResult<bool> { IsError = true, Message = "No logged in user" };

    var user = AppUserRepository.All.FirstOrDefault(u => u.Username == username);
    DateTime now = TimeService.GetCurrentUtcDateTime();

    if (user != null)
    {
        user.IsEnabled = true;

        // do not count if the last access was quite recent
        if ((now - (user.LastAccessTime ?? new DateTime(2018, 1, 1))).TotalHours > 8)
            user.AccessCount++;

        user.LastAccessTime = now;
        DataAccess.SaveChanges();
        return ret;
    }

    // fetching A/D info to use in newly created record
    var userInfoRes = ActiveDirectoryService.GetUserInfoByLogOn(username);
    if (userInfoRes.IsError)
    {
        string msg = $"Could not find A/D info for user {username}";
        Logger.LogError(msg);
    }

    Logger.LogInfo("Creating non-existent user {username}");

    // user does not exist, creating it with minimum rights
    var userInfo = userInfoRes.Payload;
    var dbAppUser = new AppUser
    {
        Email = userInfo?.EmailAddress ?? "[email protected]",
        FirstName = userInfo?.FirstName ?? "<no_first_name>",
        LastName = userInfo?.LastName ?? "<no last name>",
        IsEnabled = true,
        Username = username,
        UserPrincipalName = userInfo?.UserPrincipalName ?? "<no UPN>",
        IsSuperUser = false,
        LastAccessTime = TimeService.GetCurrentUtcDateTime(),
        AccessCount = 1
    };

    AppUserRepository.Insert(dbAppUser);
    DataAccess.SaveChanges();

    ret.Payload = true;
    return ret;
}

The code is called from a middleware:

/// <summary>
/// 
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public async Task Invoke(HttpContext context)
{
    try
    {
        if (context.Request.Method == "OPTIONS")
        {
            await _next(context);
            return;
        }

        ISecurityService securityService =
            (ISecurityService) context.RequestServices.GetService(typeof(ISecurityService));
        securityService.EnsureCurrentUserIsRegistered();
    }
    catch (Exception exc)
    {
        ILoggingService logger =
            (ILoggingService)context.RequestServices.GetService(typeof(ILoggingService));
        logger.LogError(exc, "Failed to ensure current user is registered");
    }

    await _next(context);
}

The UI is a SPA that might trigger multiple requests in the same time and since the above logic is not thread safe, it sometimes fails.

I am thinking about how to easily implement a thread safety mechanism:

  • use a session to store access information
  • define a ConcurrentDictionary (username => access info) and store the information there

However, this looks rather convoluted and I am wondering if there is any built in mechanism that allows some code to be executed in a critical section at user level.

Question: How to efficiently ensure thread safety for code being executed in parallel for the same client/user?


Solution

  • The key is fault tolerance. Unless you gate the logic using a semaphore (lock) to allow only one operation at a time (which is obviously going to hinder performance), then there is no way to ensure that multiple simultaneous operations are not going to happen for the same user.

    Instead, you need to focus on planning for that, and having a strategy to handle when it occurs. If you're modifying a particular table row, you can employ optimistic concurrency to ensure that a write cannot successfully occur unless the row is at the same state as when the operation began. This is handled via a column that stores a concurrency token, which is updated on each write. If the concurrency token at the beginning of the operation doesn't match what's actually in the database at the time of the update, then it will throw an exception, which you can then handle.

    If it's a situation where multiple writes do not necessarily result in different data, you can simply catch and ignore the thrown exception (though you still will probably want to at least log them).

    Long and short, how you handle the concurrency conflict is highly dependent on the specific scenario, but in all cases, there's some way to gracefully recover. That is what you should be focusing on.