diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/InboundApi/RouteToInstanceRequest.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/InboundApi/RouteToInstanceRequest.cs index f308a732..0798bbc5 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/InboundApi/RouteToInstanceRequest.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/InboundApi/RouteToInstanceRequest.cs @@ -83,3 +83,46 @@ public record RouteToSetAttributesResponse( bool Success, string? ErrorMessage, DateTimeOffset Timestamp); + +/// +/// Request to block until a remote instance attribute reaches a target value +/// (spec §6 — Route.To("inst").WaitForAttribute(name, targetValue, timeout)). +/// Value-equality ONLY across the wire: carries the +/// canonical AttributeValueCodec-encoded target; there is no predicate and no +/// quality flag in the comparison. The site evaluates equality and either matches or +/// times out. +/// +/// +/// Audit Log #23 (ParentExecutionId): mirrors . +/// For an inbound-API-routed wait this is the inbound request's per-request execution id; +/// future site-side audit emission for routed waits can stamp it as ParentExecutionId +/// so the inbound→site execution-tree link survives the wait path. Additive trailing +/// member — null for the Central UI sandbox path or for callers built before the field existed. +/// +public record RouteToWaitForAttributeRequest( + string CorrelationId, + string InstanceUniqueName, + string AttributeName, + string? TargetValueEncoded, + TimeSpan Timeout, + DateTimeOffset Timestamp, + Guid? ParentExecutionId = null); + +/// +/// Response from a remote attribute wait. / +/// convey the routing-level outcome (e.g. instance-not-found); , +/// , , and convey the wait +/// outcome itself. When is true, exactly one of +/// / holds: means the +/// attribute reached the target value (with / +/// captured at the match), means the deadline elapsed first. +/// +public record RouteToWaitForAttributeResponse( + string CorrelationId, + bool Matched, + object? Value, + string? Quality, + bool TimedOut, + bool Success, + string? ErrorMessage, + DateTimeOffset Timestamp); diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs index 7cec3a3e..3b866e35 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs @@ -445,6 +445,25 @@ public class CommunicationService envelope, _options.IntegrationTimeout, cancellationToken); } + /// + /// Routes an inbound API wait-for-attribute request to a site (spec §6). + /// + /// The target site identifier. + /// The wait-for-attribute route request. + /// Cancellation token. + /// The wait-for-attribute route response. + public async Task RouteToWaitForAttributeAsync( + string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken = default) + { + var envelope = new SiteEnvelope(siteId, request); + // A wait legitimately blocks up to request.Timeout on the site, so the cluster + // Ask must be bounded by the WAIT deadline (plus integration-timeout slack for + // the round trip), not the generic IntegrationTimeout used by the other routes. + var askTimeout = request.Timeout + _options.IntegrationTimeout; + return await GetActor().Ask( + envelope, askTimeout, cancellationToken); + } + // ── Notification Outbox (central-local actor — Asked directly, no SiteEnvelope) ── /// diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/CommunicationServiceInstanceRouter.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/CommunicationServiceInstanceRouter.cs index 01e001e1..d306ec33 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/CommunicationServiceInstanceRouter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/CommunicationServiceInstanceRouter.cs @@ -35,4 +35,9 @@ public sealed class CommunicationServiceInstanceRouter : IInstanceRouter public Task RouteToSetAttributesAsync( string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken) => _communicationService.RouteToSetAttributesAsync(siteId, request, cancellationToken); + + /// + public Task RouteToWaitForAttributeAsync( + string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken) => + _communicationService.RouteToWaitForAttributeAsync(siteId, request, cancellationToken); } diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/IInstanceRouter.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/IInstanceRouter.cs index 1d08d123..65a00535 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/IInstanceRouter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/IInstanceRouter.cs @@ -34,4 +34,12 @@ public interface IInstanceRouter /// A task that resolves to the set-attributes response from the target site. Task RouteToSetAttributesAsync( string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken); + + /// Routes a wait-for-attribute request to the specified site (spec §6). + /// Target site identifier. + /// The wait-for-attribute request to route (value-equality only). + /// Cancellation token for the routed call. + /// A task that resolves to the wait-for-attribute response from the target site. + Task RouteToWaitForAttributeAsync( + string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken); } diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs index 76c02a97..b335f232 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs @@ -205,6 +205,47 @@ public class RouteTarget return response.Values; } + /// + /// Blocks until a remote instance attribute reaches + /// or elapses (spec §6). Value-equality ONLY across the + /// wire: the target is canonically encoded via and + /// the site evaluates equality — there is no predicate and no quality flag in the + /// comparison. + /// + /// Name of the attribute to wait on. + /// Target value the attribute must equal for the wait to match. + /// Maximum time to wait for the attribute to reach the target value. + /// Optional cancellation token; defaults to the method deadline. + /// A task that resolves to true if the attribute reached the target value, false if the wait timed out. + public async Task WaitForAttribute( + string attributeName, + object? targetValue, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + var token = Effective(cancellationToken); + var siteId = await ResolveSiteAsync(token); + + // Audit Log #23 (ParentExecutionId): mirrors the Call path — stamp the + // spawning inbound request's ExecutionId so future site-side audit + // emission for routed waits can record this wait's parent. CorrelationId + // is the per-operation lifecycle id, freshly minted per routed wait. + var request = new RouteToWaitForAttributeRequest( + Guid.NewGuid().ToString(), _instanceCode, attributeName, + AttributeValueCodec.Encode(targetValue), timeout, DateTimeOffset.UtcNow, + _parentExecutionId); + + var response = await _instanceRouter.RouteToWaitForAttributeAsync(siteId, request, token); + + if (!response.Success) + { + throw new InvalidOperationException( + response.ErrorMessage ?? "Remote attribute wait failed"); + } + + return response.Matched; + } + /// /// Sets a single attribute value on the remote instance. /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs index 99459613..1b21ffec 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs @@ -623,5 +623,11 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture RouteToSetAttributesAsync( string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); + + // WaitForAttribute is not part of this fixture's routed-Call audit scenario; + // mirror the other non-Call methods (unexercised here). + public Task RouteToWaitForAttributeAsync( + string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken) + => throw new NotSupportedException(); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTests.cs index 4abcf17a..a812f205 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTests.cs @@ -1,6 +1,7 @@ using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; +using ZB.MOM.WW.ScadaBridge.Commons.Types; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; @@ -139,6 +140,116 @@ public class RouteHelperTests Assert.Equal("read failed", ex.Message); } + // --- WaitForAttribute (spec §6) --- + + [Fact] + public async Task WaitForAttribute_Matched_ReturnsTrue() + { + SiteResolves("inst-1", "SiteA"); + _router.RouteToWaitForAttributeAsync("SiteA", Arg.Any(), Arg.Any()) + .Returns(ci => new RouteToWaitForAttributeResponse( + ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId, + Matched: true, Value: true, Quality: "Good", TimedOut: false, + Success: true, ErrorMessage: null, DateTimeOffset.UtcNow)); + + var matched = await CreateHelper().To("inst-1") + .WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30)); + + Assert.True(matched); + } + + [Fact] + public async Task WaitForAttribute_TimedOut_ReturnsFalse() + { + SiteResolves("inst-1", "SiteA"); + _router.RouteToWaitForAttributeAsync("SiteA", Arg.Any(), Arg.Any()) + .Returns(ci => new RouteToWaitForAttributeResponse( + ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId, + Matched: false, Value: null, Quality: null, TimedOut: true, + Success: true, ErrorMessage: null, DateTimeOffset.UtcNow)); + + var matched = await CreateHelper().To("inst-1") + .WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30)); + + Assert.False(matched); + } + + [Fact] + public async Task WaitForAttribute_RoutingFailure_ThrowsInvalidOperationException() + { + // Success=false is a routing-level outcome (e.g. instance not found on the + // site), distinct from the wait outcome (Matched/TimedOut). + SiteResolves("inst-1", "SiteA"); + _router.RouteToWaitForAttributeAsync("SiteA", Arg.Any(), Arg.Any()) + .Returns(ci => new RouteToWaitForAttributeResponse( + ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId, + Matched: false, Value: null, Quality: null, TimedOut: false, + Success: false, ErrorMessage: "instance not found", DateTimeOffset.UtcNow)); + + var ex = await Assert.ThrowsAsync( + () => CreateHelper().To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30))); + Assert.Equal("instance not found", ex.Message); + } + + [Fact] + public async Task WaitForAttribute_EncodesTargetValue_OnRequest() + { + // Value-equality only across the wire: the target value is encoded via the + // canonical AttributeValueCodec, identical to how attribute values travel. + SiteResolves("inst-1", "SiteA"); + RouteToWaitForAttributeRequest? captured = null; + _router.RouteToWaitForAttributeAsync("SiteA", Arg.Do(r => captured = r), Arg.Any()) + .Returns(ci => new RouteToWaitForAttributeResponse( + ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId, + Matched: true, Value: true, Quality: "Good", TimedOut: false, + Success: true, ErrorMessage: null, DateTimeOffset.UtcNow)); + + await CreateHelper().To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30)); + + Assert.NotNull(captured); + Assert.Equal("Flag", captured!.AttributeName); + Assert.Equal(TimeSpan.FromSeconds(30), captured.Timeout); + Assert.Equal(AttributeValueCodec.Encode(true), captured.TargetValueEncoded); + Assert.True(Guid.TryParse(captured.CorrelationId, out _)); + } + + [Fact] + public async Task WaitForAttribute_WithNoExplicitToken_InheritsMethodDeadlineToken() + { + SiteResolves("inst-1", "SiteA"); + using var deadline = new CancellationTokenSource(); + CancellationToken seen = default; + _router.RouteToWaitForAttributeAsync("SiteA", Arg.Any(), Arg.Do(t => seen = t)) + .Returns(ci => new RouteToWaitForAttributeResponse( + ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId, + Matched: false, Value: null, Quality: null, TimedOut: true, + Success: true, ErrorMessage: null, DateTimeOffset.UtcNow)); + + var bound = CreateHelper().WithDeadline(deadline.Token); + await bound.To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30)); + + Assert.Equal(deadline.Token, seen); + } + + [Fact] + public async Task WaitForAttribute_WithParentExecutionId_CarriesItOnRequest() + { + SiteResolves("inst-1", "SiteA"); + var inboundExecutionId = Guid.NewGuid(); + RouteToWaitForAttributeRequest? captured = null; + _router.RouteToWaitForAttributeAsync("SiteA", Arg.Do(r => captured = r), Arg.Any()) + .Returns(ci => new RouteToWaitForAttributeResponse( + ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId, + Matched: true, Value: true, Quality: "Good", TimedOut: false, + Success: true, ErrorMessage: null, DateTimeOffset.UtcNow)); + + var bound = CreateHelper().WithParentExecutionId(inboundExecutionId); + await bound.To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30)); + + Assert.NotNull(captured); + Assert.Equal(inboundExecutionId, captured!.ParentExecutionId); + } + // --- SetAttribute(s) --- [Fact]