Resolve all 46 low-severity architecture review findings
This commit is contained in:
@@ -16,6 +16,9 @@ public sealed class KpiHistoryOptions
|
||||
/// </summary>
|
||||
public TimeSpan SampleInterval { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>Maximum wait for one KPI source (default 10 seconds). Unfinished sources remain owned and are skipped on later ticks.</summary>
|
||||
public TimeSpan SourceTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Central retention window in days for recorded KPI samples (default 90,
|
||||
/// range <c>[1, 3650]</c>). Rows older than this are dropped by the purge
|
||||
|
||||
@@ -46,6 +46,9 @@ public sealed class KpiHistoryOptionsValidator : OptionsValidatorBase<KpiHistory
|
||||
/// <inheritdoc />
|
||||
protected override void Validate(ValidationBuilder builder, KpiHistoryOptions options)
|
||||
{
|
||||
builder.RequireThat(options.SourceTimeout > TimeSpan.Zero && options.SourceTimeout <= TimeSpan.FromDays(1),
|
||||
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.SourceTimeout)} must be > 0 and <= 1 day.");
|
||||
|
||||
builder.RequireThat(options.SampleInterval > TimeSpan.Zero,
|
||||
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.SampleInterval)} ({options.SampleInterval}) " +
|
||||
"must be > 0; it is the recorder's sampling cadence.");
|
||||
|
||||
@@ -58,9 +58,10 @@ namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
|
||||
/// <para>
|
||||
/// <b>DI scopes.</b> <see cref="IKpiSampleSource"/>s and
|
||||
/// <see cref="IKpiHistoryRepository"/> are scoped EF Core-backed services; the
|
||||
/// recorder opens a fresh <see cref="IServiceScope"/> per tick and resolves
|
||||
/// there, asynchronously disposing it (<c>CreateAsyncScope</c>) so EF Core's
|
||||
/// async connection cleanup does not block.
|
||||
/// recorder opens a fresh <see cref="IServiceScope"/> for the tick's repository
|
||||
/// and a separate scope for each source collection. A timed-out collection owns
|
||||
/// its scope until actual completion, while later sources use separate DbContexts.
|
||||
/// Scopes are asynchronously disposed (<c>CreateAsyncScope</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Singleton wiring.</b> The <c>Props</c> is built in the Host on the
|
||||
@@ -87,6 +88,8 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
|
||||
/// </summary>
|
||||
private static readonly TimeSpan BackfillInitialDelay = TimeSpan.FromSeconds(15);
|
||||
|
||||
// Shared by every recorder incarnation on this root provider.
|
||||
private readonly KpiSourceAdmission _sourceAdmission;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly KpiHistoryOptions _options;
|
||||
private readonly ILogger<KpiHistoryRecorderActor> _logger;
|
||||
@@ -176,6 +179,7 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
|
||||
ILogger<KpiHistoryRecorderActor> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
_sourceAdmission = serviceProvider.GetRequiredService<KpiSourceAdmission>();
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
@@ -307,9 +311,8 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a single sampling pass: opens a DI scope, enumerates every registered
|
||||
/// <see cref="IKpiSampleSource"/>, collects each source's samples (isolating per-source
|
||||
/// faults), and bulk-writes the combined batch. The whole body is wrapped so the
|
||||
/// Runs a single sampling pass: enumerates every registered <see cref="IKpiSampleSource"/>,
|
||||
/// waits a bounded time for each separately scoped collection, and bulk-writes the combined batch. The whole body is wrapped so the
|
||||
/// returned task never faults — best-effort observability must never disrupt anything.
|
||||
/// </summary>
|
||||
private async Task RunSamplePass(DateTime capturedAt, CancellationToken cancellationToken)
|
||||
@@ -317,17 +320,32 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
|
||||
try
|
||||
{
|
||||
await using var scope = _serviceProvider.CreateAsyncScope();
|
||||
var sources = scope.ServiceProvider.GetServices<IKpiSampleSource>();
|
||||
var sources = scope.ServiceProvider.GetServices<IKpiSampleSource>().ToArray();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();
|
||||
|
||||
var samples = new List<KpiSample>();
|
||||
foreach (var source in sources)
|
||||
for (var sourceIndex = 0; sourceIndex < sources.Length; sourceIndex++)
|
||||
{
|
||||
var sourceName = sources[sourceIndex].Source;
|
||||
// Each call owns a separate scope, so a cancellation-ignoring source
|
||||
// neither shares a busy DbContext with healthy sources nor loses its scope.
|
||||
var index = sourceIndex;
|
||||
if (!_sourceAdmission.TryStart(index,
|
||||
() => CollectSourceAsync(index, capturedAt, cancellationToken), out var collection))
|
||||
continue;
|
||||
_ = collection.ContinueWith(t =>
|
||||
_logger.LogError(t.Exception, "KPI source {Source} collection failed.", sourceName),
|
||||
CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
try
|
||||
{
|
||||
var collected = await source.CollectAsync(capturedAt, cancellationToken);
|
||||
var collected = await collection.WaitAsync(_options.SourceTimeout, cancellationToken);
|
||||
samples.AddRange(collected);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_logger.LogWarning("KPI source {Source} exceeded its collection deadline; unfinished work remains owned and will not be restarted.", sourceName);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Shutdown interrupted collection; abandon the rest of the pass. The next
|
||||
@@ -337,7 +355,7 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A throwing source must NOT abort the pass or the other sources.
|
||||
_logger.LogError(ex, "KPI source {Source} failed to collect samples.", source.Source);
|
||||
_logger.LogError(ex, "KPI source {Source} failed to collect samples.", sourceName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +385,17 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Owns a source's scope and cancellation source until its actual collection completes, even after the caller stops waiting.</summary>
|
||||
private async Task<IReadOnlyList<KpiSample>> CollectSourceAsync(
|
||||
int sourceIndex, DateTime capturedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _serviceProvider.CreateAsyncScope();
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
deadline.CancelAfter(_options.SourceTimeout);
|
||||
var source = scope.ServiceProvider.GetServices<IKpiSampleSource>().ElementAt(sourceIndex);
|
||||
return await source.CollectAsync(capturedAt, deadline.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a purge tick: computes both retention cut-offs on the actor thread (raw samples
|
||||
/// at <see cref="KpiHistoryOptions.RetentionDays"/>, hourly rollups at the longer
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
|
||||
|
||||
/// <summary>
|
||||
/// Root-provider-owned admission for source collections. Actor replacement must not
|
||||
/// start another call while an earlier cancellation-ignoring collection still owns its scope.
|
||||
/// Registration indices are stable because the root provider's service collection is immutable.
|
||||
/// </summary>
|
||||
internal sealed class KpiSourceAdmission
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<int, Task<IReadOnlyList<KpiSample>>> _tasks = new();
|
||||
|
||||
internal bool TryStart(
|
||||
int registrationIndex,
|
||||
Func<Task<IReadOnlyList<KpiSample>>> collect,
|
||||
out Task<IReadOnlyList<KpiSample>> collection)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_tasks.TryGetValue(registrationIndex, out var existing) && !existing.IsCompleted)
|
||||
{
|
||||
collection = existing;
|
||||
return false;
|
||||
}
|
||||
|
||||
collection = Task.Run(collect);
|
||||
_tasks[registrationIndex] = collection;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
|
||||
@@ -22,7 +23,8 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
/// <summary>
|
||||
/// Registers the KPI History component services: the validated
|
||||
/// <see cref="KpiHistoryOptions"/> binding. Idempotent re-registration of the
|
||||
/// <see cref="KpiHistoryOptions"/> binding and provider-owned source admission.
|
||||
/// Idempotent re-registration of the
|
||||
/// validator is handled by the shared <c>AddValidatedOptions</c> helper
|
||||
/// (<c>TryAddEnumerable</c>); the options binding itself is call-once per
|
||||
/// <see cref="IServiceCollection"/>.
|
||||
@@ -38,6 +40,7 @@ public static class ServiceCollectionExtensions
|
||||
// Binds the "ScadaBridge:KpiHistory" section, registers the validator,
|
||||
// and enables ValidateOnStart in one call — same shape as AddAuditLog.
|
||||
services.AddValidatedOptions<KpiHistoryOptions, KpiHistoryOptionsValidator>(configuration, OptionsSection);
|
||||
services.TryAddSingleton<KpiSourceAdmission>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user