Make remote localization startup non-blocking

This commit is contained in:
master
2026-03-11 10:07:30 +02:00
parent 7a1c090f2e
commit 5c874c8f64
8 changed files with 299 additions and 18 deletions

View File

@@ -11,6 +11,7 @@ namespace StellaOps.Localization;
/// </summary>
public sealed class RemoteBundleProvider : ITranslationBundleProvider
{
private static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(3);
private readonly TranslationOptions _options;
private readonly IHttpClientFactory? _httpClientFactory;
private readonly ILogger<RemoteBundleProvider> _logger;
@@ -45,7 +46,13 @@ public sealed class RemoteBundleProvider : ITranslationBundleProvider
try
{
var client = _httpClientFactory.CreateClient("StellaOpsLocalization");
var response = await client.GetAsync(url, ct).ConfigureAwait(false);
var requestTimeout = ResolveRequestTimeout();
client.Timeout = requestTimeout;
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(requestTimeout);
var response = await client.GetAsync(url, timeoutCts.Token).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
@@ -78,6 +85,13 @@ public sealed class RemoteBundleProvider : ITranslationBundleProvider
return Task.FromResult<IReadOnlyList<string>>([]);
}
private TimeSpan ResolveRequestTimeout()
{
return _options.RemoteBundleRequestTimeout > TimeSpan.Zero
? _options.RemoteBundleRequestTimeout
: DefaultRequestTimeout;
}
private sealed class RemoteBundleResponse
{
public string? Locale { get; set; }

View File

@@ -22,6 +22,9 @@ public sealed class TranslationOptions
/// <summary>Cache TTL for remote bundles.</summary>
public TimeSpan RemoteBundleCacheDuration { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>Maximum time to wait for a single remote bundle request during startup.</summary>
public TimeSpan RemoteBundleRequestTimeout { get; set; } = TimeSpan.FromSeconds(3);
/// <summary>Whether to return the key as fallback when translation is missing.</summary>
public bool ReturnKeyWhenMissing { get; set; } = true;
}

View File

@@ -44,28 +44,28 @@ public sealed class TranslationRegistry
// Ensure default locale is always loaded
allLocales.Add(_options.DefaultLocale);
// Load bundles in priority order (lower first, higher overwrites)
// Load bundles in priority order (lower first, higher overwrites).
// Locales within the same provider are independent, so load them concurrently
// and merge back in deterministic locale order.
foreach (var provider in ordered)
{
foreach (var locale in allLocales)
var loadTasks = allLocales
.OrderBy(locale => locale, StringComparer.OrdinalIgnoreCase)
.Select(locale => LoadProviderBundleAsync(provider, locale, ct))
.ToArray();
var results = await Task.WhenAll(loadTasks).ConfigureAwait(false);
foreach (var result in results)
{
try
if (result.Bundle.Count == 0)
{
var bundle = await provider.LoadAsync(locale, ct).ConfigureAwait(false);
if (bundle.Count > 0)
{
MergeBundles(locale, bundle);
_logger.LogDebug(
"Loaded {Count} translations for locale {Locale} from provider (priority {Priority})",
bundle.Count, locale, provider.Priority);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to load translations for locale {Locale} from provider (priority {Priority})",
locale, provider.Priority);
continue;
}
MergeBundles(result.Locale, result.Bundle);
_logger.LogDebug(
"Loaded {Count} translations for locale {Locale} from provider (priority {Priority})",
result.Bundle.Count, result.Locale, provider.Priority);
}
}
@@ -75,6 +75,27 @@ public sealed class TranslationRegistry
_store.Count, totalKeys);
}
private async Task<ProviderLocaleBundle> LoadProviderBundleAsync(
ITranslationBundleProvider provider,
string locale,
CancellationToken ct)
{
try
{
var bundle = await provider.LoadAsync(locale, ct).ConfigureAwait(false);
return new ProviderLocaleBundle(locale, bundle);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to load translations for locale {Locale} from provider (priority {Priority})",
locale, provider.Priority);
return new ProviderLocaleBundle(
locale,
new Dictionary<string, string>(StringComparer.Ordinal));
}
}
/// <summary>
/// Merges a bundle into the store. Higher-priority values overwrite lower.
/// </summary>
@@ -248,4 +269,6 @@ public sealed class TranslationRegistry
_ => value.ToString() ?? string.Empty
};
}
private sealed record ProviderLocaleBundle(string Locale, IReadOnlyDictionary<string, string> Bundle);
}

View File

@@ -0,0 +1,58 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Localization;
namespace StellaOps.Localization.Tests;
public sealed class RemoteBundleProviderTests
{
[Fact]
public async Task LoadAsync_ReturnsEmptyBundle_WhenRemoteFetchTimesOut()
{
using var client = new HttpClient(new BlockingMessageHandler());
var provider = new RemoteBundleProvider(
Options.Create(new TranslationOptions
{
EnableRemoteBundles = true,
RemoteBundleUrl = "http://platform.stella-ops.local",
RemoteBundleRequestTimeout = TimeSpan.FromMilliseconds(100)
}),
NullLogger<RemoteBundleProvider>.Instance,
new FixedHttpClientFactory(client));
var stopwatch = Stopwatch.StartNew();
var bundle = await provider.LoadAsync("en-US", TestContext.Current.CancellationToken);
stopwatch.Stop();
Assert.Empty(bundle);
Assert.InRange(stopwatch.Elapsed, TimeSpan.Zero, TimeSpan.FromSeconds(1));
}
private sealed class FixedHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public FixedHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class BlockingMessageHandler : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new { locale = "en-US", strings = new Dictionary<string, string>() })
};
}
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Localization/StellaOps.Localization.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,93 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Localization;
namespace StellaOps.Localization.Tests;
public sealed class TranslationRegistryTests
{
[Fact]
public async Task LoadAsync_LoadsLocalesConcurrentlyWithinSingleProvider()
{
var provider = new BlockingProvider("en-US", "de-DE", "bg-BG");
var registry = new TranslationRegistry(
Options.Create(new TranslationOptions
{
DefaultLocale = "en-US",
SupportedLocales = ["en-US", "de-DE", "bg-BG"]
}),
NullLogger<TranslationRegistry>.Instance);
var loadTask = registry.LoadAsync([provider], TestContext.Current.CancellationToken);
await provider.AllLocalesStarted.Task.WaitAsync(TestContext.Current.CancellationToken);
Assert.True(provider.MaxConcurrentLoads >= 2);
provider.Release();
await loadTask;
Assert.Equal("bundle:de-DE", registry.GetBundle("de-DE")["translation.loaded"]);
Assert.Equal("bundle:bg-BG", registry.GetBundle("bg-BG")["translation.loaded"]);
}
private sealed class BlockingProvider : ITranslationBundleProvider
{
private readonly IReadOnlyList<string> _locales;
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _activeLoads;
private int _startedLoads;
private int _maxConcurrentLoads;
public BlockingProvider(params string[] locales)
{
_locales = locales;
}
public int Priority => 10;
public int MaxConcurrentLoads => _maxConcurrentLoads;
public TaskCompletionSource AllLocalesStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<IReadOnlyList<string>> GetAvailableLocalesAsync(CancellationToken ct)
=> Task.FromResult(_locales);
public async Task<IReadOnlyDictionary<string, string>> LoadAsync(string locale, CancellationToken ct)
{
var concurrentLoads = Interlocked.Increment(ref _activeLoads);
UpdateMaxConcurrentLoads(concurrentLoads);
if (Interlocked.Increment(ref _startedLoads) == _locales.Count)
{
AllLocalesStarted.TrySetResult();
}
await _release.Task.WaitAsync(ct);
Interlocked.Decrement(ref _activeLoads);
return new Dictionary<string, string>(StringComparer.Ordinal)
{
["translation.loaded"] = $"bundle:{locale}"
};
}
public void Release() => _release.TrySetResult();
private void UpdateMaxConcurrentLoads(int concurrentLoads)
{
while (true)
{
var snapshot = _maxConcurrentLoads;
if (concurrentLoads <= snapshot)
{
return;
}
if (Interlocked.CompareExchange(ref _maxConcurrentLoads, concurrentLoads, snapshot) == snapshot)
{
return;
}
}
}
}
}