using Akka.Actor; 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; /// /// C3/N3: a routed call failed because the target site never answered — the routed /// Ask expired. Unreachability is typed at the Ask boundary from /// (the communication layer's own signal: an /// unreachable/contactless site has its enveloped message dropped so the Ask times /// out caller-side) — it is never inferred from an error-message substring. /// Derives from so existing callers that /// catch the base type are unaffected, while /// catches this more specific type to surface the spec's SITE_UNREACHABLE /// error code instead of a generic SCRIPT_ERROR. A Success=false /// response means the site answered (reachable by definition) and stays a /// plain . /// public sealed class SiteUnreachableException : InvalidOperationException { /// Initializes a new with the given message. /// The routing-level failure message that indicated the site was unreachable. public SiteUnreachableException(string message) : base(message) { } /// Initializes a new wrapping the underlying Ask-timeout. /// The routing-level failure message. /// The underlying from the routed Ask. public SiteUnreachableException(string message, Exception innerException) : base(message, innerException) { } } /// /// Route.To() helper for cross-site calls from inbound API scripts. /// Resolves instance to site, routes via , blocks until /// response or timeout. Site unreachable returns error (no store-and-forward). /// /// The helper carries the executing method's /// (the method-level timeout). Routed calls inherit that deadline by default, so a /// natural script — Route.To("inst").Call("doWork", p) — is timeout-bounded /// without the script having to thread a token explicitly. /// public class RouteHelper { private readonly IInstanceLocator _instanceLocator; private readonly IInstanceRouter _instanceRouter; private readonly CancellationToken _deadlineToken; private readonly CancellationToken _requestAbortedToken; private readonly Guid? _parentExecutionId; /// /// Initializes a new with no deadline token and no parent execution id. /// /// Service to resolve the site id for a given instance code. /// Service to route cross-site calls to the resolved site. public RouteHelper( IInstanceLocator instanceLocator, IInstanceRouter instanceRouter) : this(instanceLocator, instanceRouter, CancellationToken.None, CancellationToken.None, parentExecutionId: null) { } private RouteHelper( IInstanceLocator instanceLocator, IInstanceRouter instanceRouter, CancellationToken deadlineToken, CancellationToken requestAbortedToken, Guid? parentExecutionId) { _instanceLocator = instanceLocator; _instanceRouter = instanceRouter; _deadlineToken = deadlineToken; _requestAbortedToken = requestAbortedToken; _parentExecutionId = parentExecutionId; } /// /// Returns a whose routed calls inherit /// (the executing method's timeout) by default. /// calls this when it builds the script /// context so the method timeout actually covers routed calls, as the design doc /// requires. /// /// The executing method's timeout cancellation token to inherit for routed calls. /// A new inheriting the given deadline token. public RouteHelper WithDeadline(CancellationToken deadlineToken) => new(_instanceLocator, _instanceRouter, deadlineToken, _requestAbortedToken, _parentExecutionId); /// /// Returns a carrying the raw request-abort /// token (a client disconnect) separately from the method deadline. Most /// routed calls remain bounded by the method deadline (which already incorporates the /// abort), but uses this token so its wait /// is bounded by the WAIT timeout — not the generic method deadline (spec §6) — while /// still being cancelled by a client disconnect. /// /// The raw client-disconnect token, independent of the method timeout. /// A new carrying the given request-abort token. public RouteHelper WithRequestAborted(CancellationToken requestAbortedToken) => new(_instanceLocator, _instanceRouter, _deadlineToken, requestAbortedToken, _parentExecutionId); /// /// Audit Log (ParentExecutionId): returns a whose /// routed requests carry /// as . /// For an inbound API request this is the inbound request's own per-request /// execution id, so the routed site script records the inbound request as its /// parent. calls this when it builds the /// script context. /// /// The inbound request's execution id to stamp as the spawning parent on routed calls, or null for non-routed runs. /// A new carrying the given parent execution id. public RouteHelper WithParentExecutionId(Guid? parentExecutionId) => new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, parentExecutionId); /// /// Creates a route target for the specified instance. /// /// The unique code of the instance to route calls to. /// A bound to the specified instance. public RouteTarget To(string instanceCode) { return new RouteTarget( instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId); } } /// /// Represents a route target (an instance) for cross-site calls. /// public class RouteTarget { private readonly string _instanceCode; private readonly IInstanceLocator _instanceLocator; private readonly IInstanceRouter _instanceRouter; private readonly CancellationToken _deadlineToken; private readonly CancellationToken _requestAbortedToken; private readonly Guid? _parentExecutionId; /// /// Initializes a new for the specified instance. /// /// The unique code of the target instance. /// Service to resolve the site id for the instance. /// Service to route cross-site calls. /// Cancellation token representing the method-level deadline. /// Raw client-disconnect token, independent of the method timeout. /// Optional parent execution id for audit correlation on routed calls. internal RouteTarget( string instanceCode, IInstanceLocator instanceLocator, IInstanceRouter instanceRouter, CancellationToken deadlineToken, CancellationToken requestAbortedToken, Guid? parentExecutionId) { _instanceCode = instanceCode; _instanceLocator = instanceLocator; _instanceRouter = instanceRouter; _deadlineToken = deadlineToken; _requestAbortedToken = requestAbortedToken; _parentExecutionId = parentExecutionId; } /// /// Calls a script on the remote instance. Synchronous from API caller's /// perspective. may be a dictionary or an /// anonymous object (new { name = "Bob" }) — see . /// /// When is not supplied the /// routed call inherits the executing method's timeout, so the call is bounded by /// the method-level deadline with no token argument. /// /// Name of the script to call on the remote instance. /// Optional parameters passed to the script; may be a dictionary or anonymous object. /// Optional cancellation token; defaults to the method deadline when not supplied. /// A task that resolves to the remote script's return value, or null. public async Task Call( string scriptName, object? parameters = null, CancellationToken cancellationToken = default) { var token = Effective(cancellationToken); var siteId = await ResolveSiteAsync(token); var correlationId = Guid.NewGuid().ToString(); // Audit Log (ParentExecutionId): stamp the spawning execution's id // (the inbound API request's ExecutionId) so the routed site script // records this call's parent. CorrelationId above is a separate concern // — the per-operation lifecycle id, freshly minted per routed call. var request = new RouteToCallRequest( correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters), DateTimeOffset.UtcNow, _parentExecutionId); var response = await RouteOrUnreachable(_instanceRouter.RouteToCallAsync(siteId, request, token)); if (!response.Success) { throw RoutingFailure( response.ErrorMessage ?? "Remote script call failed"); } return response.ReturnValue; } /// /// Gets a single attribute value from the remote instance. /// /// Name of the attribute to read. /// Optional cancellation token; defaults to the method deadline. /// A task that resolves to the attribute value, or null if the key is absent. public async Task GetAttribute( string attributeName, CancellationToken cancellationToken = default) { var result = await GetAttributes(new[] { attributeName }, cancellationToken); return result.TryGetValue(attributeName, out var value) ? value : null; } /// /// Gets multiple attribute values from the remote instance (batch read). /// /// Names of the attributes to read. /// Optional cancellation token; defaults to the method deadline. /// A task that resolves to a map of attribute names to their current values. public async Task> GetAttributes( IEnumerable attributeNames, CancellationToken cancellationToken = default) { var token = Effective(cancellationToken); var siteId = await ResolveSiteAsync(token); var correlationId = Guid.NewGuid().ToString(); // Audit Log (ParentExecutionId): mirrors the Call path — stamp the // spawning inbound request's ExecutionId so future site-side audit // emission for routed reads can record this read's parent. Symmetric // with RouteToCallRequest so script authors get the same correlation // across Call / GetAttributes / SetAttributes. var request = new RouteToGetAttributesRequest( correlationId, _instanceCode, attributeNames.ToList(), DateTimeOffset.UtcNow, _parentExecutionId); var response = await RouteOrUnreachable(_instanceRouter.RouteToGetAttributesAsync(siteId, request, token)); if (!response.Success) { throw RoutingFailure( response.ErrorMessage ?? "Remote attribute read failed"); } 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. /// /// /// Unlike the other routed calls (which inherit the method deadline /// by default), the wait is bounded by — the WAIT /// timeout — NOT the generic method deadline. The SITE enforces /// and returns Matched=false when it elapses; the local token is built from a /// per-wait CTS (the wait timeout plus a small grace), an explicit caller token, and /// the client-disconnect token, but deliberately EXCLUDES the method deadline. So a /// wait longer than the method timeout runs to its full wait timeout, as spec §6 /// requires, while a client disconnect still cancels it. /// /// /// 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; this — not the method deadline — bounds the wait. /// Optional explicit cancellation token (a tighter caller-supplied bound); the wait is otherwise bounded by . /// 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) { // Do NOT impose a local wait-timeout backstop here. The site // enforces the wait `timeout` and returns Matched=false when it elapses, and the // cluster Ask in CommunicationService.RouteToWaitForAttributeAsync already bounds // the round trip by `timeout + IntegrationTimeout` — the authoritative backstop // for a missing site response. A local CTS of `timeout + small grace` (the prior // approach) was TIGHTER than that round-trip budget, so a // slow-but-valid timed-out response could be cancelled into an exception instead // of the spec-mandated `false`. Link ONLY the client-disconnect token and an // explicit caller token — NOT the method deadline — so a client abort still // cancels the wait while the wait timeout itself governs the duration. using var linked = CancellationTokenSource.CreateLinkedTokenSource( _requestAbortedToken, cancellationToken); var token = linked.Token; var siteId = await ResolveSiteAsync(token); // Audit Log (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 RouteOrUnreachable(_instanceRouter.RouteToWaitForAttributeAsync(siteId, request, token)); if (!response.Success) { throw RoutingFailure( response.ErrorMessage ?? "Remote attribute wait failed"); } return response.Matched; } /// /// Sets a single attribute value on the remote instance. /// /// Name of the attribute to write. /// Value to set on the attribute. /// Optional cancellation token; defaults to the method deadline. /// A task that represents the asynchronous operation. public async Task SetAttribute( string attributeName, string value, CancellationToken cancellationToken = default) { await SetAttributes( new Dictionary { { attributeName, value } }, cancellationToken); } /// /// Sets multiple attribute values on the remote instance (batch write). /// /// Map of attribute names to values to write. /// Optional cancellation token; defaults to the method deadline. /// A task that represents the asynchronous operation. public async Task SetAttributes( IReadOnlyDictionary attributeValues, CancellationToken cancellationToken = default) { var token = Effective(cancellationToken); var siteId = await ResolveSiteAsync(token); var correlationId = Guid.NewGuid().ToString(); // Audit Log (ParentExecutionId): mirrors the Call path — stamp the // spawning inbound request's ExecutionId so future site-side audit // emission for routed writes can record this write's parent. Symmetric // with RouteToCallRequest so script authors get the same correlation // across Call / GetAttributes / SetAttributes. var request = new RouteToSetAttributesRequest( correlationId, _instanceCode, attributeValues, DateTimeOffset.UtcNow, _parentExecutionId); var response = await RouteOrUnreachable(_instanceRouter.RouteToSetAttributesAsync(siteId, request, token)); if (!response.Success) { throw RoutingFailure( response.ErrorMessage ?? "Remote attribute write failed"); } } /// /// A routed call with no explicit token inherits the executing /// method's deadline. An explicitly supplied token (for a tighter bound) wins. /// private CancellationToken Effective(CancellationToken explicitToken) => explicitToken.CanBeCanceled ? explicitToken : _deadlineToken; private async Task ResolveSiteAsync(CancellationToken cancellationToken) { var siteId = await _instanceLocator.GetSiteIdForInstanceAsync(_instanceCode, cancellationToken); if (siteId == null) { // A null site id is a plain not-found / no-assigned-site — never // unreachability (nothing was routed), so it stays a generic // InvalidOperationException (SCRIPT_ERROR). throw RoutingFailure( $"Instance '{_instanceCode}' not found or has no assigned site"); } return siteId; } /// /// N3: awaits a routed Ask and TYPES unreachability. AskTimeoutException means /// the site never answered (no ClusterClient contact, site down, partition) — /// the communication layer's own typed signal (CentralCommunicationActor drops /// envelopes for contactless sites so the Ask expires caller-side) — and /// surfaces as SiteUnreachableException so the executor maps it to the spec's /// SITE_UNREACHABLE code. A Success=false RESPONSE means the site answered and /// is therefore reachable; it flows through RoutingFailure as a plain /// InvalidOperationException (SCRIPT_ERROR). /// private static async Task RouteOrUnreachable(Task routedAsk) { try { return await routedAsk; } catch (AskTimeoutException ex) { throw new SiteUnreachableException( "Site did not respond to the routed request within the routing timeout — unreachable or not connected.", ex); } } /// /// C3/N3: a routing-level failure REPORTED BY the site or the locator. The /// remote end answered (or the instance simply has no site), so this is never /// unreachability — always a plain /// (SCRIPT_ERROR at the endpoint). Unreachability is typed at the Ask boundary /// by RouteOrUnreachable (AskTimeoutException → SiteUnreachableException), /// replacing the old message-substring sniff that a harmless rewording in the /// communication layer could silently defeat. /// private static InvalidOperationException RoutingFailure(string message) => new(message); }