Base solution for your next web application
Open Closed

Slow translations in background jobs #12625


User avatar
0
niengineering created

Dear Support,

I have an Azure App with a per connection Redis Premium. I'm facing a slowness in anexport process. This export function take around 1000 items and during the calculation makes a extension usage of the translation system (calling the L(...) method). The function is working properly when used in a normal context but becomes super slow (minutes) when called from a background job. I guess there are some dependencies from some other environment variable but I wasn't able to identify to root cause of the issue.

Markdown is supported
Copy & paste or drag & drop images (max 30 MB per image)

5 Answer(s)
  • User Avatar
    0
    oguzhanagir created
    Support Team

    Hi @niengineering

    Thank you for the detailed explanation. This behavior is expected and is related to how the Abp.AspNetCore.PerRequestRedisCache works internally.

    In ASP.NET Zero / ABP Framework, localization results are cached using PerRequestRedisCache. This cache relies on HttpContext to store localization values in an in memory layer (HttpContext.Items) during a web request.

    However, in a background job, there is no active HTTP request, so HttpContext is always null. As a result, the in memory per request cache layer is bypassed, and every call to L(...) directly queries Redis.

    Each localization call typically results in 1-2 Redis round trips (tenant + host fallback). When processing large datasets (for example, 1000 items with multiple localization calls per item), this can lead to thousands of Redis calls, causing significant latency, especially when using Azure Redis over network.

    This is why the export runs fast during normal HTTP requests but becomes slow in background jobs.

    Recommended Solutions

    Preload localization values once

    Instead of calling L(...) repeatedly inside loops, preload the required localization strings once and reuse them:

    var localizationSource = LocalizationManager.GetSource(AbpZeroTemplateConsts.LocalizationSourceName);
    
    var localizedStrings = new Dictionary<string, string>();
    
    foreach (var key in neededKeys)
    {
        localizedStrings[key] = localizationSource.GetString(key);
    }
    
    // Use localizedStrings[key] inside loops instead of calling L(...)
    

    This ensures Redis is accessed only once per key, not once per iteration.

    Use batch localization methods

    ABP provides batch methods that load multiple localization values efficiently:

    var source = LocalizationManager.GetSource(AbpZeroTemplateConsts.LocalizationSourceName);
    
    var strings = source.GetStrings(keyList, CultureInfo.CurrentUICulture);
    

    This reduces Redis round trips significantly by loading values in bulk.

    This is not related to Azure Redis configuration or environment variables. It is caused by the absence of HttpContext in background jobs, which disables the per request in memory cache layer. Optimizing localization access by preloading or batching localization values will resolve the performance issue.

    Please let us know if you need assistance implementing one of these approaches.

    We will review this scenario and evaluate framework level enhancements to provide better on premise support and improve localization performance in backend business environments.

    Markdown is supported
    Copy & paste or drag & drop images (max 30 MB per image)
  • User Avatar
    0
    niengineering created

    Clear, but this means a large review of a lot of code.

    Do you think we can bypass this issue creating in some way an HttpContext simulating the needed day for the translation system?

    I forgot to mention that the exact scenario is that the background job initiate an app service proxy call and that the translations are called inside the service proxy method.

    Markdown is supported
    Copy & paste or drag & drop images (max 30 MB per image)
  • User Avatar
    0
    oguzhanagir created
    Support Team

    Hi @niengineering

    Yes, you can work around this without reviewing all your code. Since your background job calls an app service proxy and the translations happen inside that method, you can simulate an HttpContext at the beginning of your background job. This will enable the per request in memory cache layer for localization, so repeated L(...) calls within the same job execution will be served from memory instead of hitting Redis every time.

    In your background job's Execute method, add the following before calling the app service:

    var httpContextAccessor = IocManager.Resolve<IHttpContextAccessor>();
    httpContextAccessor.HttpContext = new DefaultHttpContext();
    

    This creates a minimal HttpContext with an empty Items dictionary, which is all the PerRequestRedisCache needs to function. The first call to each localization key will still go to Redis, but all subsequent calls within the same job execution will be served from the in-memory HttpContext.Items cache exactly the same behavior as during a normal HTTP request.

    Make sure to clean up at the end:

    try
    {
        httpContextAccessor.HttpContext = new DefaultHttpContext();
        
        // ... your existing job logic (app service call, etc.)
    }
    finally
    {
        httpContextAccessor.HttpContext = null;
    }
    

    This is a single point change in your background job class no need to modify your app service or any localization calls.

    Additionally, we have created a framework improvement issue to introduce an AsyncLocal-based fallback cache when HttpContext is not available. This will allow background jobs and other non-HTTP execution contexts to benefit from the in-memory per-scope caching layer automatically without requiring manual HttpContext simulation.

    Please feel free to contact us if you have any questions. Thank you.

    Markdown is supported
    Copy & paste or drag & drop images (max 30 MB per image)
  • User Avatar
    0
    niengineering created

    It is working faster but looks it's not loading the correct localization environment. Is it possible? Can we force it? I see that even forcing session tenant id and user id is not enough to have the correct translation

    Markdown is supported
    Copy & paste or drag & drop images (max 30 MB per image)
  • User Avatar
    0
    oguzhanagir created
    Support Team

    Hi @niengineering

    Yes, this is expected. Creating an empty DefaultHttpContext() fixes the Redis caching issue (making it faster), but it does not set up the culture/language correctly. Here's why and how to fix it.

    Why the wrong language is loaded

    In a normal HTTP request, the UseAbpRequestLocalization() middleware runs and resolves the correct language by checking (in order):

    • user language setting
    • tenant default language
    • application default
    • cookie
    • Accept-Language header

    It then sets Thread.CurrentThread.CurrentUICulture, which is what L(...) reads.

    With new DefaultHttpContext(), this middleware never executes. The thread culture remains the server's OS default (e.g., en-US), so all L(...) calls return translations for that default culture regardless of the tenant/user's actual language preference.

    The Fix

    In the background job, after creating the DefaultHttpContext and setting the tenant/user session, you need to explicitly resolve and set the culture.

    The correct pattern already exists in the codebase at UserCollectedDataPrepareJob.cs:

    // Inside your background job's Execute method:
    try
    {
        var httpContextAccessor = IocManager.Resolve<IHttpContextAccessor>();
        httpContextAccessor.HttpContext = new DefaultHttpContext();
    
        // Resolve the user's (or tenant's) preferred language from settings
        var userLanguage = await SettingManager.GetSettingValueForUserAsync(
            LocalizationSettingNames.DefaultLanguage,
            new UserIdentifier(tenantId, userId)
        );
    
        // Or if you only know the tenant (not a specific user):
        // var tenantLanguage = await SettingManager.GetSettingValueForTenantAsync(
        //     LocalizationSettingNames.DefaultLanguage, tenantId);
    
        var culture = CultureHelper.GetCultureInfoByChecking(userLanguage);
    
        using (CultureInfoHelper.Use(culture))
        {
            // All L(...) calls inside this block will use the correct language
            // ... your existing export logic here ...
        }
    }
    finally
    {
        httpContextAccessor.HttpContext = null;
    }
    

    CultureInfoHelper.Use(culture) sets both Thread.CurrentThread.CurrentCulture and Thread.CurrentThread.CurrentUICulture for the duration of the using block, ensuring that all L(...) calls resolve to the correct language.

    This is the same pattern used in UserCollectedDataPrepareJob, which is the only background job in the codebase that correctly handles culture resolution.

    If this explanation is insufficient, or if you need further assistance on a different matter, please feel free to contact us.

    Thank you

    Markdown is supported
    Copy & paste or drag & drop images (max 30 MB per image)