From a73cbf8c0cfb525814e9d971f5153e34166f3f67 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 19 Sep 2026 08:34:02 +0700 Subject: [PATCH] perf(scl): reuse short-lived save enrichment snapshot --- .../NativeIec61850Client.CanonicalModel.cs | 23 +++++++++++++++++++ .../CanonicalLiveSclExportRegressionTests.cs | 19 +++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/Services/NativeIec61850Client.CanonicalModel.cs b/Services/NativeIec61850Client.CanonicalModel.cs index ffb53e4e1..6d9ea6498 100644 --- a/Services/NativeIec61850Client.CanonicalModel.cs +++ b/Services/NativeIec61850Client.CanonicalModel.cs @@ -7,6 +7,11 @@ public sealed partial class NativeIec61850Client { private LiveIedCanonicalModel? _liveCanonicalModel; private ArMms.InitialFcReadExecutionResult? _liveInitialFcRead; + private LiveIedModelDiscoveryDocument? _saveSclEnrichmentModel; + private DateTimeOffset _saveSclEnrichmentCapturedAtUtc = DateTimeOffset.MinValue; + private static readonly TimeSpan SaveSclEnrichmentReuseWindow = TimeSpan.FromSeconds(15); + + public bool LastSaveSclEnrichmentReused { get; private set; } /// /// Canonical live snapshot bound to the accepted MMS association that produced @@ -56,6 +61,19 @@ public async Task EnrichCanonicalForSclSaveAsync( if (_liveModel is null) throw new InvalidOperationException("Save-SCL enrichment requires a successful live discovery model."); + var now = DateTimeOffset.UtcNow; + if (_liveCanonicalModel is not null && + _liveInitialFcRead is not null && + ReferenceEquals(_saveSclEnrichmentModel, _liveModel) && + _saveSclEnrichmentCapturedAtUtc != DateTimeOffset.MinValue && + now - _saveSclEnrichmentCapturedAtUtc <= SaveSclEnrichmentReuseWindow) + { + LastSaveSclEnrichmentReused = true; + return _liveCanonicalModel; + } + + LastSaveSclEnrichmentReused = false; + var sourcePlan = ArMms.InitialFcReadPlanner.FromSclModel(_liveModel); var safeTargets = sourcePlan.Targets .Where(target => SaveSclEnrichmentFunctionalConstraints.Contains( @@ -92,6 +110,8 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut } PublishCanonicalModel(_liveModel, initialRead); + _saveSclEnrichmentModel = _liveModel; + _saveSclEnrichmentCapturedAtUtc = DateTimeOffset.UtcNow; return _liveCanonicalModel ?? throw new InvalidOperationException("Save-SCL enrichment did not publish a canonical model."); } @@ -100,5 +120,8 @@ private void ClearCanonicalModel() { _liveInitialFcRead = null; _liveCanonicalModel = null; + _saveSclEnrichmentModel = null; + _saveSclEnrichmentCapturedAtUtc = DateTimeOffset.MinValue; + LastSaveSclEnrichmentReused = false; } } diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 67364653b..502c82c9d 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -15,6 +15,25 @@ public void NativeClient_RetainsAcceptedAssociationCanonicalSnapshot() Assert.Contains("ClearCanonicalModel();", lifecycle, StringComparison.Ordinal); } + [Fact] + public void SaveSclEnrichment_ReusesOnlyShortLivedSameModelSnapshot() + { + var canonical = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.CanonicalModel.cs")); + var lifecycle = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs")); + var client = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.cs")); + + Assert.Contains("SaveSclEnrichmentReuseWindow = TimeSpan.FromSeconds(15)", canonical, StringComparison.Ordinal); + Assert.Contains("ReferenceEquals(_saveSclEnrichmentModel, _liveModel)", canonical, StringComparison.Ordinal); + Assert.Contains("now - _saveSclEnrichmentCapturedAtUtc <= SaveSclEnrichmentReuseWindow", canonical, StringComparison.Ordinal); + Assert.Contains("LastSaveSclEnrichmentReused = true", canonical, StringComparison.Ordinal); + Assert.Contains("_saveSclEnrichmentModel = _liveModel", canonical, StringComparison.Ordinal); + Assert.Contains("_saveSclEnrichmentCapturedAtUtc = DateTimeOffset.UtcNow", canonical, StringComparison.Ordinal); + Assert.Contains("_saveSclEnrichmentModel = null", canonical, StringComparison.Ordinal); + Assert.Contains("_saveSclEnrichmentCapturedAtUtc = DateTimeOffset.MinValue", canonical, StringComparison.Ordinal); + Assert.Contains("ClearCanonicalModel();", lifecycle, StringComparison.Ordinal); + Assert.Contains("ClearCanonicalModel();", client, StringComparison.Ordinal); + } + [Fact] public void SmartDiscovery_DefersEagerFcValueReadsFromStructuralScan() {