diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs index f1d66004..9070d02a 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs @@ -43,7 +43,10 @@ public sealed class MmsCanonicalStaticAcquisitionProbePlan public sealed class MmsCanonicalStaticAcquisitionProbeOptions { + /// Maximum exact RCBs inspected per page, not a global catalog cap. public int MaxExactTargets { get; init; } = 64; + public int MaxExactTargetPagesPerInvocation { get; init; } = 8; + public string? ExactTargetContinuationToken { get; init; } public bool ReadDataSetDirectories { get; init; } = true; public IReadOnlySet CallerOwnedRcbReferences { get; init; } = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -54,6 +57,9 @@ public sealed class MmsCanonicalStaticAcquisitionProbeResult public MmsCanonicalStaticAcquisitionProbePlan Plan { get; init; } = new(); public MmsCanonicalStaticAcquisitionProbeStatus Status { get; init; } public bool NetworkProbePerformed { get; init; } + public int ExactTargetPageCount { get; init; } + public int DeferredExactTargetCount { get; init; } + public string? ExactTargetContinuationToken { get; init; } public MmsRcbAvailabilityResult? Availability { get; init; } public IReadOnlyList Warnings { get; init; } = Array.Empty(); @@ -247,47 +253,101 @@ public async Task ProbeCanonicalStatic }; } - var maxTargets = Math.Clamp(options.MaxExactTargets, 1, 512); - if (plan.ExactTargetReportControlReferences.Count > maxTargets) + var maxPerPage = Math.Clamp(options.MaxExactTargets, 1, 512); + var maxPages = Math.Clamp(options.MaxExactTargetPagesPerInvocation, 1, 32); + EnsureMmsReady(); + + var snapshots = new List(); + var probeWarnings = new List(); + var continuation = options.ExactTargetContinuationToken; + var totalExact = 0; + var pages = 0; + var targetedProbes = 0; + var stateReads = 0; + var directoryReads = 0; + var directoryHits = 0; + + while (pages < maxPages && IsMmsInitiated) { - return new MmsCanonicalStaticAcquisitionProbeResult - { - Plan = plan, - Status = MmsCanonicalStaticAcquisitionProbeStatus.TargetBudgetExceeded, - NetworkProbePerformed = false, - Warnings = plan.Warnings - .Concat(new[] - { - $"Exact static RCB target count {plan.ExactTargetReportControlReferences.Count} exceeds the configured hot-path budget {maxTargets}; no partial probe was started." - }) - .ToArray() - }; - } + cancellationToken.ThrowIfCancellationRequested(); + var page = MmsCanonicalStaticExactTargetPager.Select( + plan, liveInventory, maxPerPage, continuation, + _canonicalStaticRcbAssociationKey); + totalExact = page.TotalExactTargets; + continuation = page.ContinuationToken; + if (page.References.Count == 0) + break; - EnsureMmsReady(); + pages++; + targetedProbes += page.References.Count; + // Exact target list, never a broad RCB availability probe. + var batch = await CheckReportControlAvailabilityAsync( + liveInventory, + directory, + new MmsRcbAvailabilityOptions + { + MaxReportControls = maxPerPage, + ReadDataSetDirectories = options.ReadDataSetDirectories, + TargetReportControlReferences = page.References, + CallerOwnedRcbReferences = options.CallerOwnedRcbReferences + }, + cancellationToken).ConfigureAwait(false); + snapshots.AddRange(batch.ReportControls); + probeWarnings.AddRange(batch.Warnings); + stateReads += batch.TargetedRcbStateLogicalReadCount; + directoryReads += batch.DataSetDirectoryNetworkReadCount; + directoryHits += batch.DataSetDirectoryCacheHitCount; - var availability = await CheckReportControlAvailabilityAsync( - liveInventory, - directory, - new MmsRcbAvailabilityOptions - { - MaxReportControls = maxTargets, - ReadDataSetDirectories = options.ReadDataSetDirectories, - TargetReportControlReferences = plan.ExactTargetReportControlReferences, - CallerOwnedRcbReferences = options.CallerOwnedRcbReferences - }, - cancellationToken).ConfigureAwait(false); + // Once each resolved DataSet segment has an independently eligible + // exact RCB, additional pages provide no benefit to this one-shot + // selected acquisition. Availability is READ-ONLY; the consumer + // still verifies ordered members and user authorization before writes. + var resolvedSegments = plan.TargetResolution.Segments + .Where(segment => segment.ExactLiveReportControlReferences.Count > 0) + .ToArray(); + var allHaveEligible = resolvedSegments.Length > 0 && + resolvedSegments.All(segment => + snapshots.Any(snapshot => + segment.ExactLiveReportControlReferences.Any(reference => + string.Equals( + MmsRcbAvailabilityEvaluator.NormalizeReference(reference), + MmsRcbAvailabilityEvaluator.NormalizeReference(snapshot.Reference), + StringComparison.OrdinalIgnoreCase)) && + MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, allowCallerOwned: true, + allowReducedMissingReservationEvidence: true).IsEligible)); + if (allHaveEligible || continuation is null) + break; + } - var status = plan.UnresolvedSegmentCount > 0 - ? MmsCanonicalStaticAcquisitionProbeStatus.PartialAvailabilityChecked - : MmsCanonicalStaticAcquisitionProbeStatus.AvailabilityChecked; + var deferred = Math.Max(0, totalExact - targetedProbes); + if (continuation is not null) + probeWarnings.Add( + $"Exact target availability deferred {deferred} RCB(s) after {pages} bounded page(s); resume using ExactTargetContinuationToken."); + var availability = new MmsRcbAvailabilityResult + { + ReportControls = snapshots, + Warnings = probeWarnings.Distinct(StringComparer.Ordinal).ToArray(), + InventoryReportControlCount = liveInventory.ReportControls.Count, + TargetFilterApplied = true, + RequestedTargetReportControlCount = plan.ExactTargetReportControlReferences.Count, + MatchedTargetReportControlCount = snapshots.Count, + TargetedRcbStateLogicalReadCount = stateReads, + DataSetDirectoryNetworkReadCount = directoryReads, + DataSetDirectoryCacheHitCount = directoryHits + }; return new MmsCanonicalStaticAcquisitionProbeResult { Plan = plan, - Status = status, - NetworkProbePerformed = true, - Availability = availability, + Status = !IsMmsInitiated || pages == 0 || continuation is not null || plan.UnresolvedSegmentCount > 0 + ? MmsCanonicalStaticAcquisitionProbeStatus.PartialAvailabilityChecked + : MmsCanonicalStaticAcquisitionProbeStatus.AvailabilityChecked, + NetworkProbePerformed = pages > 0, + Availability = pages > 0 ? availability : null, + ExactTargetPageCount = pages, + DeferredExactTargetCount = deferred, + ExactTargetContinuationToken = continuation, Warnings = plan.Warnings .Concat(availability.Warnings) .Distinct(StringComparer.Ordinal) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticExactTargetPager.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticExactTargetPager.cs new file mode 100644 index 00000000..42c72f26 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticExactTargetPager.cs @@ -0,0 +1,72 @@ +using System.Security.Cryptography; +using System.Text; + +namespace AR.Iec61850.Mms; + +public sealed class MmsCanonicalStaticExactTargetPage +{ + public IReadOnlySet References { get; init; } = + new HashSet(StringComparer.Ordinal); + public int TotalExactTargets { get; init; } + public string? ContinuationToken { get; init; } + public bool HasMore => ContinuationToken is not null; +} + +/// +/// Resumable exact-target availability admission. Unlike a global 64/128 RCB +/// abort, this pure selector constrains work per page. It cannot add targets +/// missing from the verified live inventory or invent indexed RCB siblings. +/// +public static class MmsCanonicalStaticExactTargetPager +{ + public static MmsCanonicalStaticExactTargetPage Select( + MmsCanonicalStaticAcquisitionProbePlan plan, + MmsReportInventory inventory, + int maxPerPage, + string? continuationToken = null, + string? associationKey = null) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentNullException.ThrowIfNull(inventory); + if (maxPerPage <= 0) + throw new ArgumentOutOfRangeException(nameof(maxPerPage)); + if (inventory.Authority != MmsReportInventoryAuthority.LiveMmsObserved) + throw new ArgumentException("Exact target paging requires live MMS inventory.", nameof(inventory)); + + var liveReferences = inventory.ReportControls + .Select(candidate => MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.Reference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var targets = plan.ExactTargetReportControlReferences + .Select(MmsRcbAvailabilityEvaluator.NormalizeReference) + .Where(reference => reference.Length > 0 && liveReferences.Contains(reference)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(reference => reference, StringComparer.Ordinal) + .ToArray(); + var input = new StringBuilder().Append("association=") + .Append(associationKey ?? "detached").Append('\n'); + foreach (var reference in targets) + input.Append(reference).Append('\n'); + var fingerprint = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(input.ToString()))); + + var start = 0; + if (continuationToken is not null) + { + var parts = continuationToken.Split(':'); + if (parts.Length != 2 || + !string.Equals(parts[0], fingerprint, StringComparison.Ordinal) || + !int.TryParse(parts[1], out start) || start < 0 || start >= targets.Length) + throw new ArgumentException( + "Exact RCB target continuation does not match current association/catalog.", + nameof(continuationToken)); + } + var next = Math.Min(targets.Length, start + maxPerPage); + return new MmsCanonicalStaticExactTargetPage + { + TotalExactTargets = targets.Length, + References = targets.Skip(start).Take(next - start) + .ToHashSet(StringComparer.OrdinalIgnoreCase), + ContinuationToken = next < targets.Length ? $"{fingerprint}:{next}" : null + }; + } +} diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs index d2f3b82e..414c24a7 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs @@ -51,10 +51,16 @@ public sealed class MmsCanonicalStaticLiveReconciliationOptions public int UnknownPeerMaxConcurrentDomains { get; init; } = 2; /// - /// Maximum exact live RCB candidates that may be read for DatSet binding evidence. - /// The operation fails closed rather than reading an arbitrary subset. + /// Maximum RCB Read candidates PER PAGE. A partial catalog is resumable; + /// the total number of unrelated RCBs must never abort selected discovery. /// public int MaxReportControlCandidates { get; init; } = 64; + + /// Bounded batches per invocation; never a global catalog cap. + public int MaxCandidatePagesPerInvocation { get; init; } = 8; + + /// Checked catalog continuation from a previously partial reconciliation. + public string? CandidateContinuationToken { get; init; } } public sealed class MmsCanonicalStaticLiveReconciliationResult @@ -65,6 +71,9 @@ public sealed class MmsCanonicalStaticLiveReconciliationResult public int EnumeratedVariableCount { get; init; } public int CandidateReportControlCount { get; init; } public int DataSetBoundReportControlCount { get; init; } + public int ExaminedReportControlCount { get; init; } + public int DeferredReportControlCount { get; init; } + public string? CandidateContinuationToken { get; init; } public IReadOnlyList Warnings { get; init; } = Array.Empty(); public bool HasLiveInventory => @@ -73,7 +82,8 @@ public sealed class MmsCanonicalStaticLiveReconciliationResult public string Summary => $"targeted live RCB reconciliation: status={Status}, domains={Plan.Domains.Count}, " + $"rawVariables={EnumeratedVariableCount}, candidates={CandidateReportControlCount}, " + - $"datasetBound={DataSetBoundReportControlCount}, authority={Inventory.Authority}."; + $"datasetBound={DataSetBoundReportControlCount}, examined={ExaminedReportControlCount}, " + + $"deferred={DeferredReportControlCount}, authority={Inventory.Authority}."; } public sealed class MmsCanonicalStaticSmartPreparationResult @@ -181,6 +191,8 @@ private static string ResolveDomain( public sealed partial class MmsClientSession { + private string _canonicalStaticRcbAssociationKey = Guid.NewGuid().ToString("N"); + /// /// Performs bounded online reconciliation for configured static reporting without /// running full IED discovery. @@ -265,75 +277,122 @@ public async Task ReconcileCanonical DomainVariableLists = new Dictionary>(StringComparer.Ordinal) }); - var targetsByDomain = plan.Domains.ToDictionary( - target => target.Domain, - StringComparer.Ordinal); - - var candidates = observed.ReportControls - .Where(candidate => - targetsByDomain.TryGetValue(candidate.Domain, out var target) && - ((candidate.Buffered && target.NeedsBuffered) || - (!candidate.Buffered && target.NeedsUnbuffered))) - .OrderBy(candidate => candidate.Domain, StringComparer.Ordinal) - .ThenByDescending(candidate => candidate.Buffered) - .ThenBy(candidate => candidate.Reference, StringComparer.Ordinal) - .ToArray(); - - var maxCandidates = Math.Clamp(options.MaxReportControlCandidates, 1, 512); - if (candidates.Length > maxCandidates) - { - return new MmsCanonicalStaticLiveReconciliationResult - { - Plan = plan, - Status = MmsCanonicalStaticLiveReconciliationStatus.CandidateBudgetExceeded, - Inventory = emptyInventory, - EnumeratedVariableCount = variables.Values.Sum(list => list.Count), - CandidateReportControlCount = candidates.Length, - Warnings = plan.Warnings - .Concat(new[] - { - $"Target domains expose {candidates.Length} required-family RCB candidates, exceeding live reconciliation budget {maxCandidates}; no partial RCB read subset was started." - }) - .ToArray() - }; - } + // The raw RCB directory has no authoritative DatSet association yet. + // Stage A: index by the exact selected DataSet and configured references. + // Stage B: read only bounded pages of still-unknown compatible candidates. + // Never reject the complete operation because *global* RCB count is 66/300. + // Directory/catalog objects remain immutable while pages are calculated; + // successful MMS reads enrich separate candidates, not continuation state. + var maxReadsPerPage = Math.Clamp(options.MaxReportControlCandidates, 1, 512); + var maxReadPages = Math.Clamp(options.MaxCandidatePagesPerInvocation, 1, 32); + var expectedDataSets = coverage.Segments + .Where(segment => segment.SelectedSignalReferences.Length > 0) + .Select(segment => MmsRcbAvailabilityEvaluator.NormalizeReference(segment.DataSetReference)) + .Where(reference => reference.Length > 0) + .ToHashSet(StringComparer.Ordinal); var inventory = new MmsReportInventory { Authority = MmsReportInventoryAuthority.LiveMmsObserved }; - inventory.ReportControls.AddRange(candidates); - - foreach (var candidate in candidates) + var examined = 0; + var rejected = 0; + var catalogCount = 0; + var eligibleProbeCount = 0; + var pages = 0; + var continuation = options.CandidateContinuationToken; + + do { cancellationToken.ThrowIfCancellationRequested(); - - await TryReadReportControlStructureSmartAsync( - candidate, - cancellationToken) - .ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(candidate.DataSetReference)) + // The pager binds its cursor to exact catalog content and coverage. + // A changed/ambiguous cursor must fail closed, not silently skip RCBs. + var page = MmsCanonicalStaticRcbCatalogPager.Select( + coverage, observed, maxReadsPerPage, continuation, + _canonicalStaticRcbAssociationKey); + catalogCount = page.CatalogCount; + eligibleProbeCount = page.EligibleProbeCount; + continuation = page.ContinuationToken; + pages++; + + foreach (var raw in page.Candidates) { - await TryReadReportAttributeSmartAsync( - candidate, - "DatSet", - cancellationToken) - .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (!IsMmsInitiated) + break; + + // Do not mutate the directory source used for stable paging. + var candidate = new MmsReportControlCandidate + { + Domain = raw.Domain, + LogicalNode = raw.LogicalNode, + FunctionalConstraint = raw.FunctionalConstraint, + Name = raw.Name, + Reference = raw.Reference, + Buffered = raw.Buffered, + DataSetReference = raw.DataSetReference, + Attributes = raw.Attributes.ToList() + }; + examined++; + if (string.IsNullOrWhiteSpace(candidate.DataSetReference)) + { + await TryReadReportControlStructureSmartAsync( + candidate, cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(candidate.DataSetReference)) + await TryReadReportAttributeSmartAsync( + candidate, "DatSet", cancellationToken).ConfigureAwait(false); + } + + // Exact live DatSet equality is mandatory. A matching SCL RCB + // name alone cannot become an operational report target. + if (expectedDataSets.Contains( + MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference))) + inventory.ReportControls.Add(candidate); + else + rejected++; } - if (!IsMmsInitiated) + // Stop additional catalog pages once every selected DataSet has + // one exact observed binding with explicit read-only "appears free" + // evidence. This is NOT activation authorization: the subsequent + // exact-target availability/owner/directory checks still decide. + // A busy or reservation-unknown RCB never prematurely ends search. + var allTargetsHaveFreeEvidence = expectedDataSets.Count > 0 && + expectedDataSets.All(dataSet => inventory.ReportControls.Any(candidate => + string.Equals( + MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference), + dataSet, StringComparison.Ordinal) && + MmsRcbAvailabilityEvaluator.ParseBool(candidate.EnabledState) == false && + !MmsRcbAvailabilityEvaluator.HasOwner(candidate.Owner) && + (candidate.Buffered + ? MmsRcbAvailabilityEvaluator.ParseUnsigned(candidate.ReservationTimeSeconds) == 0 + : MmsRcbAvailabilityEvaluator.ParseBool(candidate.ReservationState) == false))); + if (allTargetsHaveFreeEvidence) break; - } - - var complete = IsMmsInitiated && chains.All(chain => chain.IsComplete); + } while (continuation is not null && + pages < maxReadPages && + IsMmsInitiated); + + var complete = IsMmsInitiated && + chains.All(chain => chain.IsComplete) && + continuation is null; + var coveredDataSets = inventory.ReportControls + .Select(candidate => MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference)) + .ToHashSet(StringComparer.Ordinal); + var unresolved = expectedDataSets.Where(ds => !coveredDataSets.Contains(ds)).ToArray(); var warnings = plan.Warnings .Concat(chains .Where(chain => !chain.IsComplete) .Select(chain => $"Target domain '{chain.Domain}' NamedVariable enumeration is partial: {chain.Message}")) - .Concat(candidates - .Where(candidate => string.IsNullOrWhiteSpace(candidate.DataSetReference)) - .Select(candidate => $"Live RCB '{candidate.Reference}' did not expose a usable DatSet binding.")) + .Concat(rejected > 0 + ? new[] { $"{rejected} probed RCB(s) had no exact selected live DatSet binding; not admitted for activation." } + : Array.Empty()) + .Concat(continuation is not null + ? new[] { $"Target-scoped RCB discovery deferred {Math.Max(0, eligibleProbeCount - examined)} candidate(s) after {pages} bounded page(s); resume using CandidateContinuationToken." } + : Array.Empty()) + .Concat(unresolved.Select(ds => + $"Selected DataSet '{ds}' has no verified live RCB binding in this acquisition window.")) .Distinct(StringComparer.Ordinal) .ToArray(); @@ -342,14 +401,16 @@ await TryReadReportAttributeSmartAsync( Plan = plan, Status = !IsMmsInitiated ? MmsCanonicalStaticLiveReconciliationStatus.AssociationLost - : complete + : complete && unresolved.Length == 0 ? MmsCanonicalStaticLiveReconciliationStatus.Ready : MmsCanonicalStaticLiveReconciliationStatus.Partial, Inventory = inventory, EnumeratedVariableCount = variables.Values.Sum(list => list.Count), - CandidateReportControlCount = candidates.Length, - DataSetBoundReportControlCount = candidates.Count(candidate => - !string.IsNullOrWhiteSpace(candidate.DataSetReference)), + CandidateReportControlCount = catalogCount, + DataSetBoundReportControlCount = inventory.ReportControls.Count, + ExaminedReportControlCount = examined, + DeferredReportControlCount = Math.Max(0, eligibleProbeCount - examined), + CandidateContinuationToken = continuation, Warnings = warnings }; } diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs new file mode 100644 index 00000000..f436ba52 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs @@ -0,0 +1,164 @@ +using System.Security.Cryptography; +using System.Text; +using AR.Iec61850.Engineering.Canonical; + +namespace AR.Iec61850.Mms; + +public sealed class MmsCanonicalStaticRcbCatalogPage +{ + public IReadOnlyList Candidates { get; init; } + = Array.Empty(); + public int CatalogCount { get; init; } + public int TargetBoundCount { get; init; } + public int EligibleProbeCount { get; init; } + public string? ContinuationToken { get; init; } + public bool HasMore => ContinuationToken is not null; +} + +/// +/// Read-only candidate admission for one selected canonical DataSet scope. +/// An observed DatSet binding is authority; an exact configured RCB reference +/// is only a priority for a Read, never proof of its current DatSet. +/// No sibling name guess can authorize report activation. +/// Catalog size is never a reason to abandon discovery: a page limits only +/// the number of actual read candidates and carries a checked continuation. +/// +public static class MmsCanonicalStaticRcbCatalogPager +{ + public static MmsCanonicalStaticRcbCatalogPage Select( + CanonicalStaticReportCoveragePlan coverage, + MmsReportInventory inventory, + int maxReads, + string? continuationToken = null, + string? associationKey = null) + { + ArgumentNullException.ThrowIfNull(coverage); + ArgumentNullException.ThrowIfNull(inventory); + if (maxReads <= 0) + throw new ArgumentOutOfRangeException(nameof(maxReads)); + + // Exact scoped keys are derived from selected SCL coverage, not a + // vendor-specific RCB-name pattern or an arbitrary inventory cap. + var requested = coverage.Segments + .Where(s => s.SelectedSignalReferences.Length > 0) + .SelectMany(segment => segment.ReportControls.Select(report => new + { + Domain = DomainOf(report.MmsDomain, report.Reference), + report.Buffered, + Configured = Normalize(report.Reference), + TargetDataSet = Normalize(segment.DataSetReference) + })) + .Where(target => target.Domain.Length > 0 && target.TargetDataSet.Length > 0) + .ToArray(); + + var selectedDomains = requested.Select(x => (x.Domain, x.Buffered)) + .ToHashSet(); + var targetDataSets = requested.Select(x => x.TargetDataSet) + .ToHashSet(StringComparer.Ordinal); + var configured = requested.Select(x => x.Configured) + .Where(x => x.Length > 0).ToHashSet(StringComparer.Ordinal); + + var catalog = inventory.ReportControls + .Where(c => selectedDomains.Contains((c.Domain, c.Buffered))) + .Where(c => + { + var bound = Normalize(c.DataSetReference); + return bound.Length == 0 || targetDataSets.Contains(bound); + }) + .DistinctBy(c => Normalize(c.Reference), StringComparer.Ordinal) + .OrderBy(c => c.Reference, StringComparer.Ordinal) + .ToArray(); + + var bound = catalog + .Where(c => targetDataSets.Contains(Normalize(c.DataSetReference))) + .ToArray(); + var exactUnbound = catalog + .Where(c => string.IsNullOrWhiteSpace(c.DataSetReference) && + configured.Contains(Normalize(c.Reference))) + .ToArray(); + + // A proven DataSet scope does not trigger a blind read of other, + // still-unbound RCBs. Without proof, an unknown candidate requires + // a real read of DatSet; exact configured references are probed first. + // A concrete indexed RCB instance (e.g. Events01 for declared + // Events) is ONLY a prioritization hint for a read-only DatSet probe. + // This never constitutes exact DataSet identity or activation proof. + var indexedUnbound = catalog + .Where(c => string.IsNullOrWhiteSpace(c.DataSetReference) && + !configured.Contains(Normalize(c.Reference)) && + configured.Any(reference => + IsIndexedInstance(Normalize(c.Reference), reference))) + .ToArray(); + var indexedSet = indexedUnbound.ToHashSet(); + + var probes = bound.Length > 0 + ? bound.Concat(exactUnbound).ToArray() + : exactUnbound.Concat(indexedUnbound).Concat(catalog + .Where(c => string.IsNullOrWhiteSpace(c.DataSetReference) && + !configured.Contains(Normalize(c.Reference)) && + !indexedSet.Contains(c))).ToArray(); + + // The token is bound to the exact catalog/selection. On a changed + // inventory it fails closed instead of skipping an arbitrary RCB. + var input = new StringBuilder(); + input.Append("association=").Append(associationKey ?? "detached").Append('\n'); + foreach (var target in requested + .OrderBy(t => t.Domain, StringComparer.Ordinal) + .ThenBy(t => t.Configured, StringComparer.Ordinal) + .ThenBy(t => t.TargetDataSet, StringComparer.Ordinal)) + input.Append(target.Domain).Append('|').Append(target.Buffered) + .Append('|').Append(target.Configured).Append('|') + .Append(target.TargetDataSet).Append('\n'); + foreach (var item in catalog) + input.Append(item.Reference).Append('|').Append(item.DataSetReference).Append('\n'); + var fingerprint = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(input.ToString()))); + + var start = 0; + if (continuationToken is not null) + { + var parts = continuationToken.Split(':'); + if (parts.Length != 2 || + !string.Equals(parts[0], fingerprint, StringComparison.Ordinal) || + !int.TryParse(parts[1], out start) || start < 0 || start >= probes.Length) + throw new ArgumentException( + "RCB continuation does not belong to the current target catalog.", + nameof(continuationToken)); + } + + var size = Math.Min(maxReads, probes.Length - start); + var next = start + size; + return new MmsCanonicalStaticRcbCatalogPage + { + CatalogCount = catalog.Length, + TargetBoundCount = bound.Length, + EligibleProbeCount = probes.Length, + Candidates = probes.Skip(start).Take(size).ToArray(), + ContinuationToken = next < probes.Length ? $"{fingerprint}:{next}" : null + }; + } + + private static bool IsIndexedInstance(string candidate, string configured) + { + if (configured.Length == 0 || candidate.Length <= configured.Length || + !candidate.StartsWith(configured, StringComparison.Ordinal)) + return false; + // Exact numeric instance suffix only: no arbitrary text similarity, + // and still no usable target until the live DatSet read succeeds. + for (var i = configured.Length; i < candidate.Length; i++) + if (candidate[i] is < '0' or > '9') + return false; + return true; + } + + private static string Normalize(string? reference) + => MmsRcbAvailabilityEvaluator.NormalizeReference(reference); + + private static string DomainOf(string? domain, string? reference) + { + if (!string.IsNullOrWhiteSpace(domain)) + return domain.Trim(); + var slash = reference?.IndexOf('/') ?? -1; + return slash > 0 ? reference![..slash] : string.Empty; + } +} diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs index da44d3b8..8e12ae40 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs @@ -66,6 +66,15 @@ public static MmsCanonicalStaticRcbTargetResolution Resolve( var exactReferenceMatches = liveInventory.ReportControls .Where(candidate => configuredReferences.Contains(Normalize(candidate.Reference))) + .Where(candidate => + configuredBufferedFamilies.Count == 0 || + configuredBufferedFamilies.Contains(candidate.Buffered)) + .Where(candidate => + canonicalDataSet.Length == 0 || + string.IsNullOrWhiteSpace(candidate.DataSetReference) || + string.Equals( + Normalize(candidate.DataSetReference), + canonicalDataSet, StringComparison.OrdinalIgnoreCase)) .ToArray(); var exactDataSetMatches = canonicalDataSet.Length == 0 diff --git a/src/AR.Iec61850/Mms/MmsClientSession.cs b/src/AR.Iec61850/Mms/MmsClientSession.cs index 636182e7..0ca63d33 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.cs @@ -764,6 +764,8 @@ private async Task AssociateAsync(bool resetAssociationDiagnostics, Cancellation if (result.IsAccepted) { State = MmsAssociationState.MmsInitiated; + // Bind paged RCB discovery to this accepted association. + _canonicalStaticRcbAssociationKey = Guid.NewGuid().ToString("N"); LastHandshakeMessage = result.Message; ResetReportSemanticTypeEvidence(); ResetConfiguredStaticDataSetDirectoryEvidence(); diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs index 484ebd86..d3a64b96 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs @@ -146,11 +146,14 @@ public async Task ExecutorRejectsInventoryAuthoritySwapBeforeNetworkWork() Assert.Null(result.Availability); } - [Fact] - public async Task TargetBudgetFailsBeforeAnyNetworkWork_AndNeverRunsPartialProbe() + [Theory] + [InlineData(66, 2)] + [InlineData(300, 5)] + public void Exact_Targets_Are_Paged_Without_The_Previous_Global_Budget_Abort( + int count, int expectedPages) { - var targetSet = Enumerable.Range(1, 3) - .Select(index => $"LD0/LLN0.BR.Rpt{index:00}") + var targetSet = Enumerable.Range(1, count) + .Select(index => $"LD0/LLN0.BR.Rpt{index:000}") .ToHashSet(StringComparer.OrdinalIgnoreCase); var targetResolution = new MmsCanonicalStaticRcbTargetResolution { @@ -168,35 +171,41 @@ public async Task TargetBudgetFailsBeforeAnyNetworkWork_AndNeverRunsPartialProbe var plan = new MmsCanonicalStaticAcquisitionProbePlan { InventoryAuthority = MmsReportInventoryAuthority.LiveMmsObserved, - Coverage = Coverage( - Segment( - "LD0/LLN0.Events", - "LD0/LLN0.BR.Rpt01", - buffered: true, - "LD0/XCBR1.Pos.stVal")), + Coverage = Coverage(Segment( + "LD0/LLN0.Events", "LD0/LLN0.BR.Rpt", + buffered: true, "LD0/XCBR1.Pos.stVal")), TargetResolution = targetResolution, Status = MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsReady, ExactTargetReportControlReferences = targetSet }; + var inventory = Inventory( + MmsReportInventoryAuthority.LiveMmsObserved, + targetSet.Select(r => Candidate(r, "LD0/LLN0.Events", true)).ToArray()); - await using var session = new MmsClientSession(); - var result = await session.ProbeCanonicalStaticAcquisitionAsync( - plan, - Inventory( - MmsReportInventoryAuthority.LiveMmsObserved, - Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", true), - Candidate("LD0/LLN0.BR.Rpt02", "LD0/LLN0.Events", true), - Candidate("LD0/LLN0.BR.Rpt03", "LD0/LLN0.Events", true)), - options: new MmsCanonicalStaticAcquisitionProbeOptions - { - MaxExactTargets = 2 - }); + var examined = new HashSet(StringComparer.OrdinalIgnoreCase); + string? cursor = null; + var pages = 0; + do + { + var page = MmsCanonicalStaticExactTargetPager.Select( + plan, inventory, 64, cursor, "active-association"); + Assert.InRange(page.References.Count, 1, 64); + foreach (var reference in page.References) + Assert.True(examined.Add(reference), "Duplicate exact target across pages."); + cursor = page.ContinuationToken; + pages++; + } while (cursor is not null && pages < 10); - Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.TargetBudgetExceeded, result.Status); - Assert.False(result.NetworkProbePerformed); - Assert.Null(result.Availability); - Assert.Contains(result.Warnings, warning => - warning.Contains("no partial probe", StringComparison.OrdinalIgnoreCase)); + Assert.Null(cursor); + Assert.Equal(count, examined.Count); + Assert.Equal(expectedPages, pages); + + var first = MmsCanonicalStaticExactTargetPager.Select( + plan, inventory, 64, associationKey: "active-association"); + Assert.NotNull(first.ContinuationToken); + Assert.Throws(() => + MmsCanonicalStaticExactTargetPager.Select( + plan, inventory, 64, first.ContinuationToken, "reconnected-association")); } private static CanonicalStaticReportCoveragePlan Coverage( diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs new file mode 100644 index 00000000..76beb371 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs @@ -0,0 +1,211 @@ +using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsCanonicalStaticRcbCatalogPagerTests +{ + [Theory] + [InlineData(66)] + [InlineData(300)] + public void Proven_Target_DataSet_Does_Not_Probe_Unrelated_Rcb_Inventory(int total) + { + var catalog = Enumerable.Range(0, total - 2) + .Select(i => Candidate($"LD0/LLN0.BR.Noise{i:000}", "", enabled: false)) + .Append(Candidate("LD0/LLN0.BR.Events01", "LD0/LLN0.Events", enabled: true)) + .Append(Candidate("LD0/LLN0.BR.Events02", "LD0/LLN0.Events", enabled: false)) + .ToArray(); + var first = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(catalog), 4); + var reversed = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(catalog.Reverse().ToArray()), 4); + + Assert.Equal(2, first.Candidates.Count); + Assert.Equal(first.Candidates.Select(x => x.Reference), reversed.Candidates.Select(x => x.Reference)); + Assert.False(first.HasMore); + Assert.Equal(total, first.CatalogCount); + Assert.Equal(2, first.TargetBoundCount); + Assert.DoesNotContain(first.Candidates, x => x.Name.StartsWith("Noise", StringComparison.Ordinal)); + Assert.All(first.Candidates, x => Assert.Equal("LD0/LLN0.Events", x.DataSetReference)); + + // Read-only discovery sees both matching RCBs; activation policy must + // reject the occupied one and choose the independently proven free one. + var occupied = first.Candidates.Single(x => x.EnabledState == "true"); + var free = first.Candidates.Single(x => x.EnabledState == "false"); + Assert.False(MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + VerifiedAvailability(occupied, MmsRcbOperationalAvailability.InUse, "60"), + allowCallerOwned: false, + allowReducedMissingReservationEvidence: false).IsEligible); + var eligibility = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + VerifiedAvailability(free, MmsRcbOperationalAvailability.Available, "0"), + allowCallerOwned: false, + allowReducedMissingReservationEvidence: false); + Assert.Equal(MmsConfiguredStaticRcbEligibilityKind.ExplicitFree, eligibility.Kind); + } + + private static MmsRcbAvailabilitySnapshot VerifiedAvailability( + MmsReportControlCandidate candidate, + MmsRcbOperationalAvailability state, + string reservation) + => new() + { + Reference = candidate.Reference, + Domain = candidate.Domain, + LogicalNode = candidate.LogicalNode, + Buffered = candidate.Buffered, + DataSetReference = candidate.DataSetReference, + DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded, + EnabledState = candidate.EnabledState, + ReservationTimeSeconds = reservation, + DataSetDirectorySuccess = true, + DataSetMembers = [new MmsDataSetDirectoryMember + { + Domain = "LD0", + MmsItemName = "XCBR1$ST$Pos", + UserReference = "LD0/XCBR1.Pos", + FunctionalConstraint = "ST" + }], + Availability = state, + Confidence = MmsRcbAvailabilityConfidence.Exact + }; + + [Fact] + public void Unresolved_300_Rcbs_Continues_In_Bounded_Deterministic_Pages_Without_Duplicates() + { + var catalog = Enumerable.Range(0, 300) + .Select(i => Candidate($"LD0/LLN0.BR.Unbound{i:000}", "")) + .ToArray(); + var inventory = Inventory(catalog); + string? continuation = null; + var visited = new List(); + var pages = 0; + do + { + var page = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), inventory, 64, continuation); + Assert.InRange(page.Candidates.Count, 1, 64); + visited.AddRange(page.Candidates.Select(x => x.Reference)); + continuation = page.ContinuationToken; + pages++; + } while (continuation is not null && pages < 10); + + Assert.Null(continuation); + Assert.Equal(5, pages); + Assert.Equal(300, visited.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(300, visited.Count); + } + + [Fact] + public void Indexed_Configured_Family_Is_Read_Priority_Not_Trusted_DataSet_Proof() + { + var catalog = Enumerable.Range(0, 300) + .Select(i => Candidate($"LD0/LLN0.BR.Unrelated{i:000}", "")) + .Append(Candidate("LD0/LLN0.BR.Events01", "")) + .Append(Candidate("LD0/LLN0.BR.Events02", "")) + .ToArray(); + var first = MmsCanonicalStaticRcbCatalogPager.Select( + Coverage(), Inventory(catalog), 2); + Assert.Equal(302, first.CatalogCount); + Assert.Equal(0, first.TargetBoundCount); + Assert.Equal(2, first.Candidates.Count); + Assert.Equal( + new[] { "LD0/LLN0.BR.Events01", "LD0/LLN0.BR.Events02" }, + first.Candidates.Select(c => c.Reference).ToArray()); + Assert.True(first.HasMore); + Assert.All(first.Candidates, c => Assert.Equal("", c.DataSetReference)); + } + + [Fact] + public void Catalog_Or_Target_Mutation_Invalidates_Continuation() + { + var catalog = Enumerable.Range(0, 66) + .Select(i => Candidate($"LD0/LLN0.BR.Unbound{i:000}", "")) + .ToArray(); + var initial = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(catalog), 16); + Assert.NotNull(initial.ContinuationToken); + var changed = catalog[..^1]; + Assert.Throws(() => + MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(changed), 16, initial.ContinuationToken)); + } + + [Fact] + public void Reconnect_Must_Reject_Prior_Association_Cursor() + { + var catalog = Enumerable.Range(0, 66) + .Select(i => Candidate($"LD0/LLN0.BR.Unbound{i:000}", "")) + .ToArray(); + var inventory = Inventory(catalog); + var old = MmsCanonicalStaticRcbCatalogPager.Select( + Coverage(), inventory, 16, associationKey: "epoch-a"); + Assert.NotNull(old.ContinuationToken); + Assert.Throws(() => + MmsCanonicalStaticRcbCatalogPager.Select( + Coverage(), inventory, 16, old.ContinuationToken, "epoch-b")); + } + + [Fact] + public void Wrong_Dataset_And_Different_Functional_Constraint_Are_Not_Admitted() + { + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Events", "LD0/LLN0.Wrong"), + Candidate("LD0/LLN0.RP.Events", "LD0/LLN0.Events", buffered: false), + Candidate("LD0/LLN0.BR.Events02", "LD0/LLN0.Events"), + Candidate("LD1/LLN0.BR.Events03", "LD0/LLN0.Events")); + var page = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), inventory, 32); + Assert.Equal("LD0/LLN0.BR.Events02", Assert.Single(page.Candidates).Reference); + } + + [Fact] + public void Exact_Configured_Reference_Is_Probe_Priority_But_Not_Proof_Of_Dataset() + { + var catalog = Enumerable.Range(0, 66) + .Select(i => Candidate($"LD0/LLN0.BR.Noise{i:000}", "")) + .Append(Candidate("LD0/LLN0.BR.Events", "")) + .ToArray(); + var page = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(catalog), 1); + Assert.Equal("LD0/LLN0.BR.Events", Assert.Single(page.Candidates).Reference); + Assert.Equal(0, page.TargetBoundCount); + Assert.True(page.HasMore); + } + + private static CanonicalStaticReportCoveragePlan Coverage() => new() + { + Segments = + [ + new CanonicalStaticReportCoverageSegment + { + DataSetReference = "LD0/LLN0.Events", + SourceDataSetReference = "LD0/LLN0.Events", + SelectedSignalReferences = ["LD0/XCBR1.Pos.stVal"], + ReportControls = + [ + new CanonicalReportControl + { + Reference = "LD0/LLN0.BR.Events", + MmsDomain = "LD0", + Buffered = true, + DataSetReference = "LD0/LLN0.Events" + } + ] + } + ] + }; + + private static MmsReportInventory Inventory(params MmsReportControlCandidate[] candidates) + { + var inventory = new MmsReportInventory { Authority = MmsReportInventoryAuthority.LiveMmsObserved }; + inventory.ReportControls.AddRange(candidates); + return inventory; + } + + private static MmsReportControlCandidate Candidate( + string reference, string dataSet, bool enabled = false, bool buffered = true) + => new() + { + Domain = reference[..reference.IndexOf('/')], + LogicalNode = "LLN0", + FunctionalConstraint = buffered ? "BR" : "RP", + Name = reference[(reference.LastIndexOf('.') + 1)..], + Reference = reference, + Buffered = buffered, + DataSetReference = dataSet, + EnabledState = enabled ? "true" : "false" + }; +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs index 12bcbef0..5293bf0c 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs @@ -54,6 +54,25 @@ public void Exact_Configured_Reference_Can_Target_When_Live_Dataset_Binding_Is_N Assert.Equal("exact-configured-rcb-reference", resolution.Segments.Single().Resolution); } + [Fact] + public void Exact_Configured_Reference_With_Proven_Wrong_DataSet_Cannot_Be_Target() + { + var coverage = Coverage( + dataSet: "LD0/LLN0.Events", + configured: new CanonicalReportControl + { + Reference = "LD0/LLN0.BR.Events", + DataSetReference = "LD0/LLN0.Events", + Buffered = true + }); + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Events", "LD0/LLN0.Other", buffered: true)); + + var result = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory); + Assert.False(result.HasTargets); + Assert.Equal("unresolved", Assert.Single(result.Segments).Resolution); + } + [Fact] public void Unresolved_Segment_Does_Not_Guess_Name_Family() {