- Admin-010: vendor Bootstrap 5.3.3 (CSS + JS bundle + maps + provenance README) under wwwroot/lib/bootstrap and reference local paths from App.razor — Admin no longer pulls Bootstrap from jsDelivr. - Admin-011: swap FleetStatusPoller's three plain dictionaries for ConcurrentDictionary so ResetCache can't race a poll tick. - Admin-012: drop the EquipmentId column from EquipmentCsvImporter (per admin-ui.md — equipment id is system-derived from EquipmentUuid); EquipmentImportBatchService and the textarea placeholder updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
116 lines
4.9 KiB
C#
116 lines
4.9 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Reflection;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
|
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
|
|
|
/// <summary>
|
|
/// Regression for Admin-011 — <see cref="FleetStatusPoller"/> kept three plain
|
|
/// <c>Dictionary<,></c> caches that were enumerated/mutated from the steady-state
|
|
/// poll loop and cleared from <c>ResetCache()</c> with no synchronization. A concurrent
|
|
/// <c>ResetCache()</c> during a poll iteration could throw
|
|
/// <see cref="InvalidOperationException"/> or corrupt the dictionary. The fix swaps the
|
|
/// caches for <see cref="ConcurrentDictionary{TKey,TValue}"/> so reset + concurrent
|
|
/// reads/writes are safe by construction.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FleetStatusPollerConcurrencyTests
|
|
{
|
|
[Fact]
|
|
public void Cache_fields_are_thread_safe_collections()
|
|
{
|
|
// The fix uses ConcurrentDictionary; that makes ResetCache() and concurrent
|
|
// poll-tick mutations safe by construction. Guard the structural choice with
|
|
// reflection so a future refactor cannot silently revert to plain Dictionary
|
|
// without flipping this guardrail.
|
|
var fields = typeof(FleetStatusPoller)
|
|
.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)
|
|
.Where(f => f.Name is "_last" or "_lastRole" or "_lastResilience")
|
|
.ToList();
|
|
|
|
fields.Count.ShouldBe(3, "expected the three cache fields _last/_lastRole/_lastResilience to exist");
|
|
|
|
foreach (var f in fields)
|
|
{
|
|
var type = f.FieldType;
|
|
type.IsGenericType.ShouldBeTrue($"{f.Name} should be a generic concurrent collection");
|
|
type.GetGenericTypeDefinition().ShouldBe(
|
|
typeof(ConcurrentDictionary<,>),
|
|
customMessage: $"{f.Name} must be a ConcurrentDictionary<,> so concurrent ResetCache()/poll calls are safe — plain Dictionary regressed Admin-011.");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ResetCache_is_safe_to_call_concurrently_with_cache_mutations()
|
|
{
|
|
// Stress test — hammer the cache with mutate/clear concurrently. With plain
|
|
// Dictionary this throws InvalidOperationException ("Collection was modified")
|
|
// or corrupts internal state. With ConcurrentDictionary it must complete cleanly.
|
|
var poller = BuildPollerForReflectionTest();
|
|
|
|
var lastField = typeof(FleetStatusPoller).GetField("_last", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
|
var cache = lastField.GetValue(poller)!;
|
|
var cacheType = cache.GetType();
|
|
var indexer = cacheType.GetProperty("Item")!;
|
|
|
|
var keyType = cacheType.GetGenericArguments()[0]; // string
|
|
var valueType = cacheType.GetGenericArguments()[1]; // NodeStateSnapshot record-struct
|
|
var defaultSnapshot = Activator.CreateInstance(valueType)!;
|
|
|
|
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
|
|
|
var writer = Task.Run(() =>
|
|
{
|
|
var i = 0;
|
|
while (!cts.IsCancellationRequested)
|
|
{
|
|
indexer.SetValue(cache, defaultSnapshot, new object[] { $"node-{i++ % 64}" });
|
|
}
|
|
});
|
|
var resetter = Task.Run(() =>
|
|
{
|
|
var method = typeof(FleetStatusPoller).GetMethod("ResetCache", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
|
while (!cts.IsCancellationRequested)
|
|
{
|
|
method.Invoke(poller, null);
|
|
}
|
|
});
|
|
|
|
// Should not throw — the whole point is that the two run concurrently safely.
|
|
Should.NotThrow(() => Task.WaitAll([writer, resetter]));
|
|
}
|
|
|
|
private static FleetStatusPoller BuildPollerForReflectionTest()
|
|
{
|
|
// Pass null-style stubs — the poller constructor doesn't touch them and we
|
|
// never call ExecuteAsync/PollOnceAsync here (those need a real DB context).
|
|
// We only exercise ResetCache + cache mutation by reflection.
|
|
var scopeFactory = new StubServiceScopeFactory();
|
|
var fleetHub = new StubHubContext<FleetStatusHub>();
|
|
var alertHub = new StubHubContext<AlertHub>();
|
|
return new FleetStatusPoller(
|
|
scopeFactory,
|
|
fleetHub,
|
|
alertHub,
|
|
NullLogger<FleetStatusPoller>.Instance,
|
|
new RedundancyMetrics());
|
|
}
|
|
|
|
private sealed class StubServiceScopeFactory : IServiceScopeFactory
|
|
{
|
|
public IServiceScope CreateScope() => throw new NotImplementedException();
|
|
}
|
|
|
|
private sealed class StubHubContext<THub> : IHubContext<THub> where THub : Hub
|
|
{
|
|
public IHubClients Clients => throw new NotImplementedException();
|
|
public IGroupManager Groups => throw new NotImplementedException();
|
|
}
|
|
}
|