Search code examples
c#.netentity-frameworkasp.net-corelocalization

ASP.NET Core IStringLocalizerFactory concurrency


In ASP.NET Core 3.1 project I have a custom IStringLocalizerFactory that works with database through entity framework:

public class EFStringLocalizerFactory : IStringLocalizerFactory
{
    private readonly LocalizationContext _context;
    private readonly IMemoryCache _memoryCache;
    private static readonly ConcurrentDictionary<string, IStringLocalizer> InternalLocalizersHolder = new ConcurrentDictionary<string, IStringLocalizer>();

    public EFStringLocalizerFactory(LocalizationContext context, IMemoryCache memoryCache)
    {
        _context = context;
        _memoryCache = memoryCache;
    }

    public IStringLocalizer Create(Type resourceSource)
    {
        return CreateStringLocalizer(_context, _memoryCache, resourceSource.FullName);
    }

    public IStringLocalizer Create(string baseName, string location)
    {
        return CreateStringLocalizer(_context, _memoryCache, baseName);
    }

    internal static IStringLocalizer CreateStringLocalizer(LocalizationContext context, IMemoryCache memoryCache, string resourceSection)
    {
        return InternalLocalizersHolder.GetOrAdd(resourceSection, s => new EFStringLocalizer(context, memoryCache, s));
    }
}

EFStringLocalizer class looks like this:

public class EFStringLocalizer : IStringLocalizer
{
    private readonly LocalizationContext _context;
    private readonly IMemoryCache _translationsCache;
    private readonly string _resourceSection;


    public EFStringLocalizer(LocalizationContext context, IMemoryCache memoryCache, string resourceSection)
    {
        _context = context;
        _translationsCache = memoryCache;
        _resourceSection = resourceSection;
    }

    public LocalizedString this[string name]
    {
        get
        {
            var value = GetString(name);
            return new LocalizedString(name, value ?? name, resourceNotFound: value == name);
        }
    }

    public LocalizedString this[string name, params object[] arguments]
    {
        get
        {
            var format = GetString(name);
            var value = string.Format(format ?? name, arguments);
            return new LocalizedString(name, value, resourceNotFound: format == null);
        }
    }

    public IStringLocalizer WithCulture(CultureInfo culture)
    {
        CultureInfo.DefaultThreadCurrentCulture = culture;
        return EFStringLocalizerFactory.CreateStringLocalizer(_context, _translationsCache, _resourceSection);
    }

    //TODO fix parameter usage?
    public IEnumerable<LocalizedString> GetAllStrings(bool includeAncestorCultures)
    {
        try
        {
            return _translationsCache.GetOrCreate($"LocalizerGetAllStrings-{CultureInfo.CurrentCulture.Name}-{_resourceSection}", entry =>
            {
                var keysWithTranslations = _context.Resources
                    .Include(r => r.Culture)
                    .Where(r => r.Culture.Name == CultureInfo.CurrentCulture.Name && r.Section == _resourceSection)
                    .Select(r => new LocalizedString(r.Key, r.Value)).ToList();

                return keysWithTranslations;
            });
        }
        catch (Exception e)
        {
            Console.WriteLine(e);
            throw;
        }
    }

    private string GetString(string name)
    {
        return GetAllStrings(false).FirstOrDefault(r => r.Name == name)?.Value;
    }
}

Resource/Culture classes are just POCOs stored in Database. I have the following code to register my dependencies at Startup.cs class:

public void ConfigureServices(IServiceCollection services)
{
    ...
    services.AddDbContext<LocalizationContext>(options =>
                    options.UseSqlServer(
                        Configuration.GetConnectionString("DefaultLocalizationConnection")));//still concurrency error!
    ...
    services.AddSingleton<IStringLocalizerFactory, EFStringLocalizerFactory>();
    ...
}

The problem is whenever the application recycles/is restarted there is a certain chance that i get an exception related to EF concurrency. What makes it harder is that I was not able to reliably reproduce the issue. Here is the stack trace:

2021-01-28 15:00:07.2356|ERROR|Microsoft.EntityFrameworkCore.Query|System.InvalidOperationException: A second operation started on this context before a previous operation completed. This is usually caused by different threads using the same instance of DbContext. For more information on how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913.
   at Microsoft.EntityFrameworkCore.Internal.ConcurrencyDetector.EnterCriticalSection()
   at Microsoft.EntityFrameworkCore.Query.Internal.QueryingEnumerable`1.Enumerator.MoveNext()|An exception occurred while iterating over the results of a query for context type 'DataAccess.LocalizationContext'.
System.InvalidOperationException: A second operation started on this context before a previous operation completed. This is usually caused by different threads using the same instance of DbContext. For more information on how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913.
   at Microsoft.EntityFrameworkCore.Internal.ConcurrencyDetector.EnterCriticalSection()
   at Microsoft.EntityFrameworkCore.Query.Internal.QueryingEnumerable`1.Enumerator.MoveNext()
2021-01-28 15:00:07.2615|ERROR|Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware|System.InvalidOperationException: A second operation started on this context before a previous operation completed. This is usually caused by different threads using the same instance of DbContext. For more information on how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913.
   at Microsoft.EntityFrameworkCore.Internal.ConcurrencyDetector.EnterCriticalSection()
   at Microsoft.EntityFrameworkCore.Query.Internal.QueryingEnumerable`1.Enumerator.MoveNext()
   at System.Collections.Generic.List`1..ctor(IEnumerable`1 collection)
   at System.Linq.Enumerable.ToList[TSource](IEnumerable`1 source)
   at Localization.EFStringLocalizer.<GetAllStrings>b__9_0(ICacheEntry entry) in EFStringLocalizer.cs:line 58
   at Microsoft.Extensions.Caching.Memory.CacheExtensions.GetOrCreate[TItem](IMemoryCache cache, Object key, Func`2 factory)
   at Localization.EFStringLocalizer.GetAllStrings(Boolean includeAncestorCultures) in EFStringLocalizer.cs:line 56
   at Localization.EFStringLocalizer.GetString(String name) in EFStringLocalizer.cs:line 80
   at Localization.EFStringLocalizer.get_Item(String name) in EFStringLocalizer.cs:line 30
   at Microsoft.AspNetCore.Mvc.Localization.HtmlLocalizer.get_Item(String name)
   at Microsoft.AspNetCore.Mvc.Localization.HtmlLocalizer`1.get_Item(String name)
   at AspNetCore.Views_Home_IndexNew.ExecuteAsync() in WebInterface\Views\Home\IndexNew.cshtml:line 15
   at Microsoft.AspNetCore.Mvc.Razor.RazorView.RenderPageCoreAsync(IRazorPage page, ViewContext context)
   at Microsoft.AspNetCore.Mvc.Razor.RazorView.RenderPageAsync(IRazorPage page, ViewContext context, Boolean invokeViewStarts)
   at Microsoft.AspNetCore.Mvc.Razor.RazorView.RenderAsync(ViewContext context)
   at Microsoft.AspNetCore.Mvc.ViewFeatures.ViewExecutor.ExecuteAsync(ViewContext viewContext, String contentType, Nullable`1 statusCode)
   at Microsoft.AspNetCore.Mvc.ViewFeatures.ViewExecutor.ExecuteAsync(ViewContext viewContext, String contentType, Nullable`1 statusCode)
   at Microsoft.AspNetCore.Mvc.ViewFeatures.ViewExecutor.ExecuteAsync(ActionContext actionContext, IView view, ViewDataDictionary viewData, ITempDataDictionary tempData, String contentType, Nullable`1 statusCode)
   at Microsoft.AspNetCore.Mvc.ViewFeatures.ViewResultExecutor.ExecuteAsync(ActionContext context, ViewResult result)
   at Microsoft.AspNetCore.Mvc.ViewResult.ExecuteResultAsync(ActionContext context)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResultFilterAsync>g__Awaited|29_0[TFilter,TFilterAsync](ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResultExecutedContextSealed context)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.ResultNext[TFilter,TFilterAsync](State& next, Scope& scope, Object& state, Boolean& isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.InvokeResultFilters()
--- End of stack trace from previous location where exception was thrown ---
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|24_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.InvokeFilterPipelineAsync()
--- End of stack trace from previous location where exception was thrown ---
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope)
   at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|6_0(Endpoint endpoint, Task requestTask, ILogger logger)
   at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware.<Invoke>g__AwaitMatcher|8_0(EndpointRoutingMiddleware middleware, HttpContext httpContext, Task`1 matcherTask)
   at Microsoft.AspNetCore.Localization.RequestLocalizationMiddleware.Invoke(HttpContext context)
   at WebInterface.Startup.<>c.<<AddSecurityMiddlewares>b__12_4>d.MoveNext() in Startup.cs:line 284
--- End of stack trace from previous location where exception was thrown ---
   at NWebsec.AspNetCore.Middleware.Middleware.CspMiddleware.Invoke(HttpContext context)
   at NWebsec.AspNetCore.Middleware.Middleware.MiddlewareBase.Invoke(HttpContext context)
   at NWebsec.AspNetCore.Middleware.Middleware.MiddlewareBase.Invoke(HttpContext context)
   at NWebsec.AspNetCore.Middleware.Middleware.MiddlewareBase.Invoke(HttpContext context)
   at NWebsec.AspNetCore.Middleware.Middleware.MiddlewareBase.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Diagnostics.StatusCodePagesMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware.<Invoke>g__Awaited|6_0(ExceptionHandlerMiddleware middleware, HttpContext context, Task task)|An unhandled exception has occurred while executing the request.

I believe that registering LocalizationContext with transient scope will not help as EFStringLocalizerFactory is registered as singleton anyway. Is there any better/proper way of handling concurrency within IStringLocalizerFactory aside from introducing global locks or other inefficient techniques?


Solution

  • I believe that registering LocalizationContext with transient scope will not help as EFStringLocalizerFactory is registered as singleton anyway.

    Correct.

    Is there any better/proper way of handling concurrency within IStringLocalizerFactory aside from introducing global locks or other inefficient techniques?

    Not as far as I know.

    EF Core DbContexts only support one operation at a time, which I think the error message is clear about. The other factor is that the in-memory cache implementation doesn't do any sort of locking, so the lambda expression used to create the cache entry can be executed concurrently by several consumers wanting to read from the cache.

    Explicitly locking is the way to go IMO, with two options:

    • Around the GetOrCreate method, meaning you can guarantee the EF Core query will only be run once, but no 2 consumers will be able to read from the cache concurrently; or
    • Around the EF Core query, meaning you can potentially override an existing cache entry, but consumers can then read from the cache concurrently.

    I'd personally go with option 2 and use a SemaphoreSlim instance.