fix(inbound): background active-node-only method warm-up service (review 2026-10-21 05/U3)
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-compiles every inbound API method script in the background AFTER the host has started,
|
||||
/// so the warm-up never delays the cluster join or <c>/health/*</c> (review 2026-10-21 05/U3).
|
||||
/// It used to run synchronously in <c>Program.cs</c> before <c>app.RunAsync()</c>, i.e. before
|
||||
/// any hosted service started.
|
||||
/// <para>
|
||||
/// A node only executes inbound methods while it is the active central node, so the warm-up
|
||||
/// waits (polling <see cref="IActiveNodeGate"/>) until this node is active and then runs once;
|
||||
/// a standby therefore pays nothing until a failover promotes it. The per-request lazy compile
|
||||
/// plus revision check in <see cref="InboundScriptExecutor"/> remains the correctness
|
||||
/// guarantee — this is purely an optimisation for the first requests.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class InboundMethodWarmupService : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan ActivePollInterval = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly InboundScriptExecutor _executor;
|
||||
private readonly IHostApplicationLifetime _lifetime;
|
||||
private readonly IActiveNodeGate? _gate;
|
||||
private readonly ILogger<InboundMethodWarmupService> _logger;
|
||||
|
||||
/// <summary>Initializes the warm-up service.</summary>
|
||||
/// <param name="scopeFactory">Creates the scope used to read the method list.</param>
|
||||
/// <param name="executor">The executor whose handler cache is warmed.</param>
|
||||
/// <param name="lifetime">Host lifetime; the warm-up starts only after ApplicationStarted.</param>
|
||||
/// <param name="logger">Logger for warm-up diagnostics.</param>
|
||||
/// <param name="gate">Optional active-node gate; absent means always active.</param>
|
||||
public InboundMethodWarmupService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
InboundScriptExecutor executor,
|
||||
IHostApplicationLifetime lifetime,
|
||||
ILogger<InboundMethodWarmupService> logger,
|
||||
IActiveNodeGate? gate = null)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_executor = executor;
|
||||
_lifetime = lifetime;
|
||||
_logger = logger;
|
||||
_gate = gate;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using (_lifetime.ApplicationStarted.Register(() => started.TrySetResult()))
|
||||
using (stoppingToken.Register(() => started.TrySetCanceled(stoppingToken)))
|
||||
{
|
||||
await started.Task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
while (_gate is { IsActiveNode: false })
|
||||
await Task.Delay(ActivePollInterval, stoppingToken).ConfigureAwait(false);
|
||||
|
||||
await Task.Run(() => WarmAsync(stoppingToken), stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// shutting down
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Best effort: lazy compile on first request still guarantees correctness.
|
||||
_logger.LogWarning(ex, "Inbound API method warm-up failed; methods will compile on first request.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WarmAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IInboundApiRepository>();
|
||||
var methods = await repo.GetAllApiMethodsAsync(ct).ConfigureAwait(false);
|
||||
// CompileAndRegister is safe for concurrent callers and single-flights per revision.
|
||||
Parallel.ForEach(methods, new ParallelOptions { CancellationToken = ct },
|
||||
method => _executor.CompileAndRegister(method));
|
||||
_logger.LogInformation("Inbound API warm-up compiled {Count} method(s)", methods.Count);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,9 @@ public class InboundScriptExecutor
|
||||
private const int KnownBadMethodsCap = 1000;
|
||||
private readonly ConcurrentDictionary<string, string> _knownBadMethods = new();
|
||||
|
||||
/// <summary>Diagnostic helper — whether a compiled handler is cached for the method.</summary>
|
||||
internal bool HasHandler(string methodName) => _scriptHandlers.ContainsKey(methodName);
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic helper — returns the current size of the
|
||||
/// known-bad-methods cache so tests can assert the cap is honoured. Internal
|
||||
|
||||
Reference in New Issue
Block a user