Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceSweepMetadataReadTests.cs
T
Joseph Doherty 960c35060c perf(site-runtime): instance reconcile sweep reads metadata only and fetches config_json just for the instances it repairs (review 2026-10-21 01/SR-P1)
Adds SiteStorageService.GetAllDeployedConfigMetadataAsync. The sweep decides
from the projection, then fetches single rows for its starts/restarts off-
thread and re-validates them (generation guard, revision match, pipeline/
running guards), releasing the per-revision claim of any repair that does
not act. Boot load unchanged.
2026-10-22 09:10:26 -04:00

137 lines
5.4 KiB
C#

using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Review 2026-10-21 01/SR-P1: the instance reconcile sweep compared only name / enabled /
/// revision, yet materialised every row's <c>config_json</c> on every tick — tens of MB of LOH
/// strings per minute on a healthy large site. It now reads a metadata-only projection and
/// fetches a full row only for an instance it actually starts or restarts.
/// </summary>
public class InstanceSweepMetadataReadTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
private int _fullTableReads;
private int _singleRowReads;
/// <summary>Creates a temporary LocalDb-backed site store per test.</summary>
public InstanceSweepMetadataReadTests()
{
_localDb = TestLocalDb.CreateTemp("sweep-metadata-read");
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
Shutdown();
var path = _localDb.Path;
_localDb.Dispose();
TestLocalDb.DeleteFiles(path);
}
private static string ConfigJson(string name) => JsonSerializer.Serialize(new FlattenedConfiguration
{
InstanceUniqueName = name,
Attributes = [new ResolvedAttribute { CanonicalName = "A", Value = "1", DataType = "Int32" }]
});
private async Task<List<DeployedInstance>> CountingFullLoad()
{
Interlocked.Increment(ref _fullTableReads);
return await _storage.GetAllDeployedConfigsAsync();
}
private async Task<DeployedInstance?> CountingSingleRowLoad(string name)
{
Interlocked.Increment(ref _singleRowReads);
return await _storage.GetDeployedConfigAsync(name);
}
private IActorRef CreateBootedDeploymentManager()
{
var health = new DeploymentManagerStartupProbe();
Func<Task<List<DeployedInstance>>> fullLoad = CountingFullLoad;
Func<string, Task<DeployedInstance?>> rowLoad = CountingSingleRowLoad;
var dm = ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage,
_compilationService,
_sharedScriptLibrary,
null,
new SiteRuntimeOptions { InstanceSweepIntervalSeconds = 1, StartupBatchDelayMs = 10 },
NullLogger<DeploymentManagerActor>.Instance,
null,
health,
null,
null,
null,
null,
fullLoad,
null,
null,
null,
rowLoad,
null)));
AwaitCondition(() => health.ReportCount > 0, TimeSpan.FromSeconds(20), TimeSpan.FromMilliseconds(25));
return dm;
}
private async Task<bool> IsRunningAsync(IActorRef dm, string name)
{
var snapshot = await dm.Ask<DebugViewSnapshot>(
new DebugSnapshotRequest(name, Guid.NewGuid().ToString("N")), TimeSpan.FromSeconds(5));
return !snapshot.InstanceNotFound;
}
/// <summary>A healthy site's sweep never reads a config blob: one full read (boot), zero row reads.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HealthySweep_ReadsNoConfigJson()
{
await _storage.StoreDeployedConfigAsync("P1", ConfigJson("P1"), "d1", "r1", true);
await _storage.StoreDeployedConfigAsync("P2", ConfigJson("P2"), "d2", "r2", true);
var dm = CreateBootedDeploymentManager();
await AwaitAssertAsync(async () => Assert.True(await IsRunningAsync(dm, "P2")), TimeSpan.FromSeconds(10));
await Task.Delay(TimeSpan.FromSeconds(4)); // several sweep ticks
Assert.Equal(1, Volatile.Read(ref _fullTableReads));
Assert.Equal(0, Volatile.Read(ref _singleRowReads));
}
/// <summary>
/// A repair still works: a row that appears after boot (as CDC replication would deliver it)
/// is started, its config fetched by a single-row read.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SweepStart_FetchesOnlyTheRowItStarts()
{
var dm = CreateBootedDeploymentManager();
await _storage.StoreDeployedConfigAsync("Late1", ConfigJson("Late1"), "d1", "r1", true);
await AwaitAssertAsync(async () => Assert.True(await IsRunningAsync(dm, "Late1")), TimeSpan.FromSeconds(15));
Assert.Equal(1, Volatile.Read(ref _fullTableReads));
Assert.Equal(1, Volatile.Read(ref _singleRowReads));
}
}