Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd7ca1634e | |||
| bdccdbf6dd | |||
| fa491c752b | |||
| aba228f443 | |||
| 5e493484f1 | |||
| 3e22285f09 | |||
| 120cd0b1b6 | |||
| 56949c967b | |||
| 7dec9b30f5 | |||
| 1d3c8edb44 | |||
| 58259016b0 | |||
| 864b9f4bd3 | |||
| de58872435 | |||
| 6777d49030 | |||
| 1b6ca07bb5 | |||
| 1ad0be8276 | |||
| 9328c4f657 | |||
| 0361dc1817 | |||
| ac12c150c3 | |||
| 40ca4b6908 | |||
| bf73985481 | |||
| 0a54fa5e35 | |||
| cec84bf572 |
@@ -51,6 +51,27 @@ public interface IMxGatewayCliClient : IAsyncDisposable
|
|||||||
StreamEventsRequest request,
|
StreamEventsRequest request,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Acknowledges an active MXAccess alarm condition through the gateway.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
|
||||||
|
Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
|
AcknowledgeAlarmRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attaches to the gateway's central alarm feed — the current active-alarm
|
||||||
|
/// snapshot followed by live transitions.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||||
|
IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests connection to the Galaxy Repository.
|
/// Tests connection to the Galaxy Repository.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -52,6 +52,22 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
|
|||||||
return _client.StreamEventsAsync(request, cancellationToken);
|
return _client.StreamEventsAsync(request, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
|
AcknowledgeAlarmRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _client.AcknowledgeAlarmAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _client.StreamAlarmsAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
|
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
|
||||||
TestConnectionRequest request,
|
TestConnectionRequest request,
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ public static class MxGatewayClientCli
|
|||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
"stream-events" => await StreamEventsAsync(arguments, client, standardOutput, cancellation.Token)
|
"stream-events" => await StreamEventsAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
|
"stream-alarms" => await StreamAlarmsAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
"acknowledge-alarm" => await AcknowledgeAlarmAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
"write" => await WriteAsync(arguments, client, standardOutput, cancellation.Token)
|
"write" => await WriteAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
"write2" => await Write2Async(arguments, client, standardOutput, cancellation.Token)
|
"write2" => await Write2Async(arguments, client, standardOutput, cancellation.Token)
|
||||||
@@ -1353,6 +1357,124 @@ public static class MxGatewayClientCli
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<int> StreamAlarmsAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
uint maxEvents = arguments.GetUInt32("max-events", 0);
|
||||||
|
bool json = arguments.HasFlag("json");
|
||||||
|
bool jsonLines = arguments.HasFlag("jsonl");
|
||||||
|
if (json && !jsonLines && maxEvents is 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("--json stream-alarms requires --max-events to bound aggregate output.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxEvents > MaxAggregateEvents)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"--max-events cannot exceed {MaxAggregateEvents}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages = json && !jsonLines
|
||||||
|
? new List<AlarmFeedMessage>(checked((int)maxEvents))
|
||||||
|
: [];
|
||||||
|
uint messageCount = 0;
|
||||||
|
var request = new StreamAlarmsRequest
|
||||||
|
{
|
||||||
|
ClientCorrelationId = CreateCorrelationId(),
|
||||||
|
AlarmFilterPrefix = arguments.GetOptional("filter-prefix") ?? string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (AlarmFeedMessage feedMessage in client.StreamAlarmsAsync(request, cancellationToken)
|
||||||
|
.WithCancellation(cancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (jsonLines)
|
||||||
|
{
|
||||||
|
output.WriteLine(ProtobufJsonFormatter.Format(feedMessage));
|
||||||
|
}
|
||||||
|
else if (json)
|
||||||
|
{
|
||||||
|
messages.Add(feedMessage);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
output.WriteLine(FormatAlarmFeedMessage(feedMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
messageCount++;
|
||||||
|
if (maxEvents > 0 && messageCount >= maxEvents)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Mirrors stream-events (Client.Dotnet-017): the supplied token covers
|
||||||
|
// the user's --timeout wall-clock budget and external Ctrl+C / parent
|
||||||
|
// CTS cancellation. All are graceful completion modes for a
|
||||||
|
// finite-window alarm-feed collector: emit what arrived and exit 0.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json && !jsonLines)
|
||||||
|
{
|
||||||
|
output.WriteLine(JsonSerializer.Serialize(
|
||||||
|
new { alarms = messages.Select(AlarmFeedMessageToJsonElement).ToArray() },
|
||||||
|
JsonOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<int> AcknowledgeAlarmAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var request = new AcknowledgeAlarmRequest
|
||||||
|
{
|
||||||
|
ClientCorrelationId = CreateCorrelationId(),
|
||||||
|
AlarmFullReference = arguments.GetRequired("reference"),
|
||||||
|
Comment = arguments.GetOptional("comment") ?? string.Empty,
|
||||||
|
OperatorUser = arguments.GetOptional("operator") ?? string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
return WriteReplyAsync(
|
||||||
|
client.AcknowledgeAlarmAsync(request, cancellationToken),
|
||||||
|
arguments,
|
||||||
|
output);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders one <see cref="AlarmFeedMessage"/> for the human-readable
|
||||||
|
/// (non-JSON) stream-alarms output, distinguishing the <c>payload</c> oneof
|
||||||
|
/// arms: a snapshot active alarm, the snapshot-complete sentinel, or a live
|
||||||
|
/// transition.
|
||||||
|
/// </summary>
|
||||||
|
private static string FormatAlarmFeedMessage(AlarmFeedMessage feedMessage)
|
||||||
|
{
|
||||||
|
return feedMessage.PayloadCase switch
|
||||||
|
{
|
||||||
|
AlarmFeedMessage.PayloadOneofCase.ActiveAlarm =>
|
||||||
|
$"active-alarm {ProtobufJsonFormatter.Format(feedMessage.ActiveAlarm)}",
|
||||||
|
AlarmFeedMessage.PayloadOneofCase.SnapshotComplete =>
|
||||||
|
$"snapshot-complete {feedMessage.SnapshotComplete}",
|
||||||
|
AlarmFeedMessage.PayloadOneofCase.Transition =>
|
||||||
|
$"transition {ProtobufJsonFormatter.Format(feedMessage.Transition)}",
|
||||||
|
_ => $"unknown-payload {feedMessage.PayloadCase}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement AlarmFeedMessageToJsonElement(AlarmFeedMessage feedMessage)
|
||||||
|
{
|
||||||
|
return JsonDocument.Parse(ProtobufJsonFormatter.Format(feedMessage)).RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<int> SmokeAsync(
|
private static async Task<int> SmokeAsync(
|
||||||
CliArguments arguments,
|
CliArguments arguments,
|
||||||
IMxGatewayCliClient client,
|
IMxGatewayCliClient client,
|
||||||
@@ -1908,6 +2030,8 @@ public static class MxGatewayClientCli
|
|||||||
or "bench-read-bulk"
|
or "bench-read-bulk"
|
||||||
or "bench-stream-events"
|
or "bench-stream-events"
|
||||||
or "stream-events"
|
or "stream-events"
|
||||||
|
or "stream-alarms"
|
||||||
|
or "acknowledge-alarm"
|
||||||
or "write"
|
or "write"
|
||||||
or "write2"
|
or "write2"
|
||||||
or "smoke"
|
or "smoke"
|
||||||
@@ -1966,6 +2090,8 @@ public static class MxGatewayClientCli
|
|||||||
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]");
|
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
|
writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet stream-events --session-id <id> [--max-events <n>] [--json]");
|
writer.WriteLine("mxgw-dotnet stream-events --session-id <id> [--max-events <n>] [--json]");
|
||||||
|
writer.WriteLine("mxgw-dotnet stream-alarms [--filter-prefix <ref>] [--max-events <n>] [--json] [--jsonl]");
|
||||||
|
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> [--comment <text>] [--operator <user>] [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet write --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--json]");
|
writer.WriteLine("mxgw-dotnet write --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet write2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--timestamp <iso>] [--json]");
|
writer.WriteLine("mxgw-dotnet write2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--timestamp <iso>] [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet smoke --item <ref> [--value <value> --type <type>] [--json]");
|
writer.WriteLine("mxgw-dotnet smoke --item <ref> [--value <value> --type <type>] [--json]");
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
public List<(AcknowledgeAlarmRequest Request, CallOptions CallOptions)> AcknowledgeAlarmCalls { get; } = [];
|
public List<(AcknowledgeAlarmRequest Request, CallOptions CallOptions)> AcknowledgeAlarmCalls { get; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the list of captured QueryActiveAlarmsAsync calls.
|
/// Gets the list of captured StreamAlarmsAsync calls.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<(QueryActiveAlarmsRequest Request, CallOptions CallOptions)> QueryActiveAlarmsCalls { get; } = [];
|
public List<(StreamAlarmsRequest Request, CallOptions CallOptions)> StreamAlarmsCalls { get; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the queue of exceptions to throw from AcknowledgeAlarmAsync.
|
/// Gets the queue of exceptions to throw from AcknowledgeAlarmAsync.
|
||||||
@@ -223,7 +223,6 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
? _acknowledgeReplies.Dequeue()
|
? _acknowledgeReplies.Dequeue()
|
||||||
: new AcknowledgeAlarmReply
|
: new AcknowledgeAlarmReply
|
||||||
{
|
{
|
||||||
SessionId = request.SessionId,
|
|
||||||
CorrelationId = request.ClientCorrelationId,
|
CorrelationId = request.ClientCorrelationId,
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok },
|
Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok },
|
||||||
@@ -231,20 +230,23 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records the query call and yields each enqueued snapshot.
|
/// Records the call and yields each enqueued snapshot as an active-alarm
|
||||||
|
/// feed message, then a snapshot-complete sentinel.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
QueryActiveAlarmsRequest request,
|
StreamAlarmsRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
{
|
{
|
||||||
QueryActiveAlarmsCalls.Add((request, callOptions));
|
StreamAlarmsCalls.Add((request, callOptions));
|
||||||
|
|
||||||
foreach (ActiveAlarmSnapshot snapshot in _activeAlarmSnapshots)
|
foreach (ActiveAlarmSnapshot snapshot in _activeAlarmSnapshots)
|
||||||
{
|
{
|
||||||
callOptions.CancellationToken.ThrowIfCancellationRequested();
|
callOptions.CancellationToken.ThrowIfCancellationRequested();
|
||||||
await Task.Yield();
|
await Task.Yield();
|
||||||
yield return snapshot;
|
yield return new AlarmFeedMessage { ActiveAlarm = snapshot };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
yield return new AlarmFeedMessage { SnapshotComplete = true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Enqueues an acknowledge reply.</summary>
|
/// <summary>Enqueues an acknowledge reply.</summary>
|
||||||
@@ -253,7 +255,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
_acknowledgeReplies.Enqueue(reply);
|
_acknowledgeReplies.Enqueue(reply);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Enqueues a snapshot to be yielded from QueryActiveAlarmsAsync.</summary>
|
/// <summary>Enqueues a snapshot yielded from StreamAlarmsAsync as an active-alarm message.</summary>
|
||||||
public void AddActiveAlarmSnapshot(ActiveAlarmSnapshot snapshot)
|
public void AddActiveAlarmSnapshot(ActiveAlarmSnapshot snapshot)
|
||||||
{
|
{
|
||||||
_activeAlarmSnapshots.Add(snapshot);
|
_activeAlarmSnapshots.Add(snapshot);
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ using MxGateway.Contracts.Proto;
|
|||||||
namespace MxGateway.Client.Tests;
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PR E.2 — pins the .NET SDK surface for the new alarm RPCs:
|
/// Pins the .NET SDK surface for the alarm RPCs:
|
||||||
/// <see cref="MxGatewayClient.AcknowledgeAlarmAsync"/> and
|
/// <see cref="MxGatewayClient.AcknowledgeAlarmAsync"/> and
|
||||||
/// <see cref="MxGatewayClient.QueryActiveAlarmsAsync"/>.
|
/// <see cref="MxGatewayClient.StreamAlarmsAsync"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MxGatewayClientAlarmsTests
|
public sealed class MxGatewayClientAlarmsTests
|
||||||
{
|
{
|
||||||
@@ -17,7 +17,6 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
FakeGatewayTransport transport = CreateTransport();
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
transport.AddAcknowledgeReply(new AcknowledgeAlarmReply
|
transport.AddAcknowledgeReply(new AcknowledgeAlarmReply
|
||||||
{
|
{
|
||||||
SessionId = "session-fixture",
|
|
||||||
CorrelationId = "corr-1",
|
CorrelationId = "corr-1",
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
Status = new MxStatusProxy
|
Status = new MxStatusProxy
|
||||||
@@ -31,7 +30,6 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
|
|
||||||
AcknowledgeAlarmReply reply = await client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
AcknowledgeAlarmReply reply = await client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
||||||
{
|
{
|
||||||
SessionId = "session-fixture",
|
|
||||||
ClientCorrelationId = "corr-1",
|
ClientCorrelationId = "corr-1",
|
||||||
AlarmFullReference = "Tank01.Level.HiHi",
|
AlarmFullReference = "Tank01.Level.HiHi",
|
||||||
Comment = "investigating",
|
Comment = "investigating",
|
||||||
@@ -64,7 +62,6 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
client.AcknowledgeAlarmAsync(
|
client.AcknowledgeAlarmAsync(
|
||||||
new AcknowledgeAlarmRequest
|
new AcknowledgeAlarmRequest
|
||||||
{
|
{
|
||||||
SessionId = "session-fixture",
|
|
||||||
AlarmFullReference = "Tank01.Level.HiHi",
|
AlarmFullReference = "Tank01.Level.HiHi",
|
||||||
Comment = string.Empty,
|
Comment = string.Empty,
|
||||||
OperatorUser = "alice",
|
OperatorUser = "alice",
|
||||||
@@ -87,7 +84,6 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
var ex = await Assert.ThrowsAsync<RpcException>(
|
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||||
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
||||||
{
|
{
|
||||||
SessionId = "session-fixture",
|
|
||||||
AlarmFullReference = "Tank01.Level.HiHi",
|
AlarmFullReference = "Tank01.Level.HiHi",
|
||||||
Comment = string.Empty,
|
Comment = string.Empty,
|
||||||
OperatorUser = "alice",
|
OperatorUser = "alice",
|
||||||
@@ -113,7 +109,6 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
var ex = await Assert.ThrowsAsync<MxGatewayAuthenticationException>(
|
var ex = await Assert.ThrowsAsync<MxGatewayAuthenticationException>(
|
||||||
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
||||||
{
|
{
|
||||||
SessionId = "session-fixture",
|
|
||||||
AlarmFullReference = "Tank01.Level.HiHi",
|
AlarmFullReference = "Tank01.Level.HiHi",
|
||||||
Comment = string.Empty,
|
Comment = string.Empty,
|
||||||
OperatorUser = "alice",
|
OperatorUser = "alice",
|
||||||
@@ -122,50 +117,47 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots()
|
public async Task StreamAlarmsAsync_StreamsSnapshotThenSnapshotComplete()
|
||||||
{
|
{
|
||||||
FakeGatewayTransport transport = CreateTransport();
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
|
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
|
||||||
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank02.Level.HiHi", AlarmConditionState.ActiveAcked));
|
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank02.Level.HiHi", AlarmConditionState.ActiveAcked));
|
||||||
await using MxGatewayClient client = CreateClient(transport);
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
List<ActiveAlarmSnapshot> snapshots = [];
|
List<AlarmFeedMessage> messages = [];
|
||||||
await foreach (ActiveAlarmSnapshot snapshot in client.QueryActiveAlarmsAsync(new QueryActiveAlarmsRequest
|
await foreach (AlarmFeedMessage message in client.StreamAlarmsAsync(new StreamAlarmsRequest()))
|
||||||
{
|
{
|
||||||
SessionId = "session-fixture",
|
messages.Add(message);
|
||||||
}))
|
|
||||||
{
|
|
||||||
snapshots.Add(snapshot);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Assert.Equal(2, snapshots.Count);
|
Assert.Equal(3, messages.Count);
|
||||||
Assert.Equal("Tank01.Level.HiHi", snapshots[0].AlarmFullReference);
|
Assert.Equal("Tank01.Level.HiHi", messages[0].ActiveAlarm.AlarmFullReference);
|
||||||
Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState);
|
Assert.Equal(AlarmConditionState.Active, messages[0].ActiveAlarm.CurrentState);
|
||||||
Assert.Equal(AlarmConditionState.ActiveAcked, snapshots[1].CurrentState);
|
Assert.Equal(AlarmConditionState.ActiveAcked, messages[1].ActiveAlarm.CurrentState);
|
||||||
Assert.Single(transport.QueryActiveAlarmsCalls);
|
Assert.True(messages[2].SnapshotComplete);
|
||||||
|
Assert.Single(transport.StreamAlarmsCalls);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
|
public async Task StreamAlarmsAsync_PassesFilterPrefix()
|
||||||
{
|
{
|
||||||
FakeGatewayTransport transport = CreateTransport();
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
await using MxGatewayClient client = CreateClient(transport);
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
await foreach (ActiveAlarmSnapshot _ in client.QueryActiveAlarmsAsync(new QueryActiveAlarmsRequest
|
await foreach (AlarmFeedMessage _ in client.StreamAlarmsAsync(new StreamAlarmsRequest
|
||||||
{
|
{
|
||||||
SessionId = "session-fixture",
|
|
||||||
AlarmFilterPrefix = "Tank01.",
|
AlarmFilterPrefix = "Tank01.",
|
||||||
}))
|
}))
|
||||||
{
|
{
|
||||||
// no snapshots enqueued; just verifying the request passes through
|
// only the snapshot-complete sentinel; verifying the request passes through
|
||||||
}
|
}
|
||||||
|
|
||||||
var call = Assert.Single(transport.QueryActiveAlarmsCalls);
|
var call = Assert.Single(transport.StreamAlarmsCalls);
|
||||||
Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix);
|
Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
|
public async Task StreamAlarmsAsync_HonorsCancellationDuringEnumeration()
|
||||||
{
|
{
|
||||||
FakeGatewayTransport transport = CreateTransport();
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
|
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
|
||||||
@@ -175,8 +167,8 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
using CancellationTokenSource cancellation = new();
|
using CancellationTokenSource cancellation = new();
|
||||||
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
||||||
{
|
{
|
||||||
await foreach (ActiveAlarmSnapshot _ in client.QueryActiveAlarmsAsync(
|
await foreach (AlarmFeedMessage _ in client.StreamAlarmsAsync(
|
||||||
new QueryActiveAlarmsRequest { SessionId = "session-fixture" },
|
new StreamAlarmsRequest(),
|
||||||
cancellation.Token))
|
cancellation.Token))
|
||||||
{
|
{
|
||||||
cancellation.Cancel();
|
cancellation.Cancel();
|
||||||
|
|||||||
@@ -248,6 +248,87 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
FakeCliClient fakeClient = new();
|
||||||
|
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
|
||||||
|
{
|
||||||
|
ActiveAlarm = new ActiveAlarmSnapshot { AlarmFullReference = "Tank01.Level.HiHi" },
|
||||||
|
});
|
||||||
|
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage { SnapshotComplete = true });
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"stream-alarms",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
"--filter-prefix",
|
||||||
|
"Tank01",
|
||||||
|
"--max-events",
|
||||||
|
"1",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
StreamAlarmsRequest request = Assert.Single(fakeClient.StreamAlarmsRequests);
|
||||||
|
Assert.Equal("Tank01", request.AlarmFilterPrefix);
|
||||||
|
string text = output.ToString();
|
||||||
|
Assert.Contains("active-alarm", text);
|
||||||
|
Assert.Contains("Tank01.Level.HiHi", text);
|
||||||
|
Assert.DoesNotContain("snapshot-complete", text);
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
FakeCliClient fakeClient = new();
|
||||||
|
fakeClient.AcknowledgeAlarmReplies.Enqueue(new AcknowledgeAlarmReply
|
||||||
|
{
|
||||||
|
CorrelationId = "ack-fixture",
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
Hresult = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"acknowledge-alarm",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
"--reference",
|
||||||
|
"Tank01.Level.HiHi",
|
||||||
|
"--comment",
|
||||||
|
"ack from cli",
|
||||||
|
"--operator",
|
||||||
|
"operator1",
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
AcknowledgeAlarmRequest request = Assert.Single(fakeClient.AcknowledgeAlarmRequests);
|
||||||
|
Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
|
||||||
|
Assert.Equal("ack from cli", request.Comment);
|
||||||
|
Assert.Equal("operator1", request.OperatorUser);
|
||||||
|
Assert.Contains("ack-fixture", output.ToString());
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
|
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
|
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
|
||||||
@@ -695,6 +776,41 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Queue of acknowledge-alarm replies to return.</summary>
|
||||||
|
public Queue<AcknowledgeAlarmReply> AcknowledgeAlarmReplies { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>List of received acknowledge-alarm requests.</summary>
|
||||||
|
public List<AcknowledgeAlarmRequest> AcknowledgeAlarmRequests { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>List of received stream-alarms requests.</summary>
|
||||||
|
public List<StreamAlarmsRequest> StreamAlarmsRequests { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>List of alarm feed messages to yield when streaming alarms.</summary>
|
||||||
|
public List<AlarmFeedMessage> AlarmFeedMessages { get; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
|
AcknowledgeAlarmRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
AcknowledgeAlarmRequests.Add(request);
|
||||||
|
return Task.FromResult(AcknowledgeAlarmReplies.Dequeue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
StreamAlarmsRequests.Add(request);
|
||||||
|
foreach (AlarmFeedMessage feedMessage in AlarmFeedMessages)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await Task.Yield();
|
||||||
|
yield return feedMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Galaxy test connection reply to return.</summary>
|
/// <summary>Galaxy test connection reply to return.</summary>
|
||||||
public TestConnectionReply GalaxyTestConnectionReply { get; set; } = new() { Ok = true };
|
public TestConnectionReply GalaxyTestConnectionReply { get; set; } = new() { Ok = true };
|
||||||
|
|
||||||
|
|||||||
@@ -134,8 +134,8 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
QueryActiveAlarmsRequest request,
|
StreamAlarmsRequest request,
|
||||||
CallOptions callOptions,
|
CallOptions callOptions,
|
||||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -143,12 +143,12 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
? cancellationToken
|
? cancellationToken
|
||||||
: callOptions.CancellationToken;
|
: callOptions.CancellationToken;
|
||||||
|
|
||||||
using AsyncServerStreamingCall<ActiveAlarmSnapshot> call = RawClient.QueryActiveAlarms(request, callOptions);
|
using AsyncServerStreamingCall<AlarmFeedMessage> call = RawClient.StreamAlarms(request, callOptions);
|
||||||
|
|
||||||
IAsyncStreamReader<ActiveAlarmSnapshot> responseStream = call.ResponseStream;
|
IAsyncStreamReader<AlarmFeedMessage> responseStream = call.ResponseStream;
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
ActiveAlarmSnapshot? snapshot;
|
AlarmFeedMessage? message;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
|
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
|
||||||
@@ -156,22 +156,22 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot = responseStream.Current;
|
message = responseStream.Current;
|
||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
|
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
yield return snapshot;
|
yield return message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
IAsyncEnumerable<ActiveAlarmSnapshot> IMxGatewayClientTransport.QueryActiveAlarmsAsync(
|
IAsyncEnumerable<AlarmFeedMessage> IMxGatewayClientTransport.StreamAlarmsAsync(
|
||||||
QueryActiveAlarmsRequest request,
|
StreamAlarmsRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
{
|
{
|
||||||
return QueryActiveAlarmsAsync(request, callOptions);
|
return StreamAlarmsAsync(request, callOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,13 +66,13 @@ internal interface IMxGatewayClientTransport
|
|||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Streams a snapshot of all alarms currently in Active or ActiveAcked state — the
|
/// Attaches to the gateway's central alarm feed — the current active-alarm
|
||||||
/// ConditionRefresh equivalent for the gateway.
|
/// snapshot followed by live transitions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">The query request, optionally scoped by alarm-reference prefix.</param>
|
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
|
||||||
/// <param name="callOptions">gRPC call options.</param>
|
/// <param name="callOptions">gRPC call options.</param>
|
||||||
/// <returns>An async enumerable of active-alarm snapshots.</returns>
|
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||||
IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
QueryActiveAlarmsRequest request,
|
StreamAlarmsRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,24 +205,25 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Streams a snapshot of all alarms currently Active or ActiveAcked — the gateway's
|
/// Attaches to the gateway's central alarm feed. The stream opens with one
|
||||||
/// ConditionRefresh equivalent. Used after reconnect to seed the local Part 9 state
|
/// <see cref="AlarmFeedMessage"/> per currently-active alarm (the
|
||||||
/// machine, or to reconcile alarms that may have been missed during a transport
|
/// ConditionRefresh snapshot), then a single <c>snapshot_complete</c>, then a
|
||||||
/// blip. Optionally scoped by alarm-reference prefix
|
/// <c>transition</c> for every subsequent raise / acknowledge / clear. Served
|
||||||
/// (<see cref="QueryActiveAlarmsRequest.AlarmFilterPrefix"/>) so a partial refresh
|
/// by the gateway's always-on alarm monitor — no worker session is opened, so
|
||||||
/// can target an equipment sub-tree.
|
/// any number of clients may attach. Optionally scoped by alarm-reference
|
||||||
|
/// prefix (<see cref="StreamAlarmsRequest.AlarmFilterPrefix"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">The query request, optionally scoped by alarm-reference prefix.</param>
|
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token for the stream.</param>
|
/// <param name="cancellationToken">Cancellation token for the stream.</param>
|
||||||
/// <returns>An async enumerable of active-alarm snapshots.</returns>
|
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||||
public IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
public IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
QueryActiveAlarmsRequest request,
|
StreamAlarmsRequest request,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
|
|
||||||
return _transport.QueryActiveAlarmsAsync(request, CreateStreamCallOptions(cancellationToken));
|
return _transport.StreamAlarmsAsync(request, CreateStreamCallOptions(cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err
|
|||||||
return runWrite(ctx, args[1:], stdout, stderr)
|
return runWrite(ctx, args[1:], stdout, stderr)
|
||||||
case "stream-events":
|
case "stream-events":
|
||||||
return runStreamEvents(ctx, args[1:], stdout, stderr)
|
return runStreamEvents(ctx, args[1:], stdout, stderr)
|
||||||
|
case "stream-alarms":
|
||||||
|
return runStreamAlarms(ctx, args[1:], stdout, stderr)
|
||||||
|
case "acknowledge-alarm":
|
||||||
|
return runAcknowledgeAlarm(ctx, args[1:], stdout, stderr)
|
||||||
case "smoke":
|
case "smoke":
|
||||||
return runSmoke(ctx, args[1:], stdout, stderr)
|
return runSmoke(ctx, args[1:], stdout, stderr)
|
||||||
case "galaxy-test-connection":
|
case "galaxy-test-connection":
|
||||||
@@ -816,6 +820,119 @@ func runStreamEvents(ctx context.Context, args []string, stdout, stderr io.Write
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runStreamAlarms(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("stream-alarms", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
filterPrefix := flags.String("filter-prefix", "", "alarm-reference prefix scoping the feed; empty means unscoped")
|
||||||
|
limit := flags.Int("limit", 0, "maximum feed messages to read; 0 means unbounded")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, _, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
// Mirror runStreamEvents so Ctrl+C on a long-running stream-alarms command
|
||||||
|
// cancels the gRPC stream cleanly (the gateway sees codes.Canceled rather
|
||||||
|
// than a torn TCP connection) and the deferred client.Close() actually runs.
|
||||||
|
signalCtx, stopSignals := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stopSignals()
|
||||||
|
|
||||||
|
streamCtx, cancelStream := context.WithCancel(signalCtx)
|
||||||
|
defer cancelStream()
|
||||||
|
stream, err := client.StreamAlarms(streamCtx, &mxgateway.StreamAlarmsRequest{AlarmFilterPrefix: *filterPrefix})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for {
|
||||||
|
message, err := stream.Recv()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *jsonOutput {
|
||||||
|
fmt.Fprintln(stdout, string(mustMarshalProto(message)))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(stdout, formatAlarmFeedMessage(message))
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if *limit > 0 && count >= *limit {
|
||||||
|
cancelStream()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatAlarmFeedMessage renders one AlarmFeedMessage in the CLI's plain-text
|
||||||
|
// output style, distinguishing the active-alarm snapshot, snapshot-complete
|
||||||
|
// sentinel, and transition cases of the message's payload oneof.
|
||||||
|
func formatAlarmFeedMessage(message *mxgateway.AlarmFeedMessage) string {
|
||||||
|
switch {
|
||||||
|
case message.GetActiveAlarm() != nil:
|
||||||
|
alarm := message.GetActiveAlarm()
|
||||||
|
return fmt.Sprintf("active-alarm %s state=%s severity=%d", alarm.GetAlarmFullReference(), alarm.GetCurrentState(), alarm.GetSeverity())
|
||||||
|
case message.GetSnapshotComplete():
|
||||||
|
return "snapshot-complete"
|
||||||
|
case message.GetTransition() != nil:
|
||||||
|
transition := message.GetTransition()
|
||||||
|
return fmt.Sprintf("transition %s kind=%s severity=%d", transition.GetAlarmFullReference(), transition.GetTransitionKind(), transition.GetSeverity())
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("acknowledge-alarm", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
reference := flags.String("reference", "", "full alarm reference to acknowledge")
|
||||||
|
comment := flags.String("comment", "", "operator acknowledge comment")
|
||||||
|
operator := flags.String("operator", "", "operator user performing the acknowledge")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *reference == "" {
|
||||||
|
return errors.New("reference is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
reply, err := client.AcknowledgeAlarm(ctx, &mxgateway.AcknowledgeAlarmRequest{
|
||||||
|
AlarmFullReference: *reference,
|
||||||
|
Comment: *comment,
|
||||||
|
OperatorUser: *operator,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *jsonOutput {
|
||||||
|
return writeJSON(stdout, commandReplyOutput{
|
||||||
|
Command: "acknowledge-alarm",
|
||||||
|
Options: options,
|
||||||
|
Reply: mustMarshalProto(reply),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(stdout, reply.GetHresult())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func runSmoke(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
func runSmoke(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
flags := flag.NewFlagSet("smoke", flag.ContinueOnError)
|
flags := flag.NewFlagSet("smoke", flag.ContinueOnError)
|
||||||
flags.SetOutput(stderr)
|
flags.SetOutput(stderr)
|
||||||
@@ -1120,7 +1237,7 @@ func runBatch(ctx context.Context, in io.Reader, stdout, stderr io.Writer) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeUsage(writer io.Writer) {
|
func writeUsage(writer io.Writer) {
|
||||||
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured-bulk|write-secured2-bulk|bench-read-bulk|write|stream-events|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|batch>")
|
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured-bulk|write-secured2-bulk|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|batch>")
|
||||||
}
|
}
|
||||||
|
|
||||||
func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgateway.GalaxyClient, commonOptions, error) {
|
func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgateway.GalaxyClient, commonOptions, error) {
|
||||||
|
|||||||
@@ -690,12 +690,26 @@ type GalaxyAttribute struct {
|
|||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
AttributeName string `protobuf:"bytes,1,opt,name=attribute_name,json=attributeName,proto3" json:"attribute_name,omitempty"`
|
AttributeName string `protobuf:"bytes,1,opt,name=attribute_name,json=attributeName,proto3" json:"attribute_name,omitempty"`
|
||||||
FullTagReference string `protobuf:"bytes,2,opt,name=full_tag_reference,json=fullTagReference,proto3" json:"full_tag_reference,omitempty"`
|
FullTagReference string `protobuf:"bytes,2,opt,name=full_tag_reference,json=fullTagReference,proto3" json:"full_tag_reference,omitempty"`
|
||||||
|
// Raw Galaxy SQL `dbo.data_type` identifier, passed through unchanged.
|
||||||
|
// This is NOT a member of `mxaccess_gateway.v1.MxDataType` — Galaxy's
|
||||||
|
// type enumeration is distinct from MXAccess's wire data-type enum and
|
||||||
|
// the two must not be cast or compared. The GalaxyRepository service is
|
||||||
|
// metadata-only and deliberately does not share types with
|
||||||
|
// mxaccess_gateway.proto. See docs/GalaxyRepository.md.
|
||||||
MxDataType int32 `protobuf:"varint,3,opt,name=mx_data_type,json=mxDataType,proto3" json:"mx_data_type,omitempty"`
|
MxDataType int32 `protobuf:"varint,3,opt,name=mx_data_type,json=mxDataType,proto3" json:"mx_data_type,omitempty"`
|
||||||
|
// Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
|
||||||
|
// "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
|
||||||
DataTypeName string `protobuf:"bytes,4,opt,name=data_type_name,json=dataTypeName,proto3" json:"data_type_name,omitempty"`
|
DataTypeName string `protobuf:"bytes,4,opt,name=data_type_name,json=dataTypeName,proto3" json:"data_type_name,omitempty"`
|
||||||
IsArray bool `protobuf:"varint,5,opt,name=is_array,json=isArray,proto3" json:"is_array,omitempty"`
|
IsArray bool `protobuf:"varint,5,opt,name=is_array,json=isArray,proto3" json:"is_array,omitempty"`
|
||||||
ArrayDimension int32 `protobuf:"varint,6,opt,name=array_dimension,json=arrayDimension,proto3" json:"array_dimension,omitempty"`
|
ArrayDimension int32 `protobuf:"varint,6,opt,name=array_dimension,json=arrayDimension,proto3" json:"array_dimension,omitempty"`
|
||||||
ArrayDimensionPresent bool `protobuf:"varint,7,opt,name=array_dimension_present,json=arrayDimensionPresent,proto3" json:"array_dimension_present,omitempty"`
|
ArrayDimensionPresent bool `protobuf:"varint,7,opt,name=array_dimension_present,json=arrayDimensionPresent,proto3" json:"array_dimension_present,omitempty"`
|
||||||
|
// Raw Galaxy SQL attribute-category identifier, passed through unchanged.
|
||||||
|
// Galaxy-specific; not mapped to any gateway enum. See
|
||||||
|
// docs/GalaxyRepository.md.
|
||||||
MxAttributeCategory int32 `protobuf:"varint,8,opt,name=mx_attribute_category,json=mxAttributeCategory,proto3" json:"mx_attribute_category,omitempty"`
|
MxAttributeCategory int32 `protobuf:"varint,8,opt,name=mx_attribute_category,json=mxAttributeCategory,proto3" json:"mx_attribute_category,omitempty"`
|
||||||
|
// Raw Galaxy SQL security-classification identifier, passed through
|
||||||
|
// unchanged. Galaxy-specific; not mapped to any gateway enum. See
|
||||||
|
// docs/GalaxyRepository.md.
|
||||||
SecurityClassification int32 `protobuf:"varint,9,opt,name=security_classification,json=securityClassification,proto3" json:"security_classification,omitempty"`
|
SecurityClassification int32 `protobuf:"varint,9,opt,name=security_classification,json=securityClassification,proto3" json:"security_classification,omitempty"`
|
||||||
IsHistorized bool `protobuf:"varint,10,opt,name=is_historized,json=isHistorized,proto3" json:"is_historized,omitempty"`
|
IsHistorized bool `protobuf:"varint,10,opt,name=is_historized,json=isHistorized,proto3" json:"is_historized,omitempty"`
|
||||||
IsAlarm bool `protobuf:"varint,11,opt,name=is_alarm,json=isAlarm,proto3" json:"is_alarm,omitempty"`
|
IsAlarm bool `protobuf:"varint,11,opt,name=is_alarm,json=isAlarm,proto3" json:"is_alarm,omitempty"`
|
||||||
|
|||||||
@@ -3792,6 +3792,8 @@ type WriteSecuredBulkEntry struct {
|
|||||||
ItemHandle int32 `protobuf:"varint,1,opt,name=item_handle,json=itemHandle,proto3" json:"item_handle,omitempty"`
|
ItemHandle int32 `protobuf:"varint,1,opt,name=item_handle,json=itemHandle,proto3" json:"item_handle,omitempty"`
|
||||||
CurrentUserId int32 `protobuf:"varint,2,opt,name=current_user_id,json=currentUserId,proto3" json:"current_user_id,omitempty"`
|
CurrentUserId int32 `protobuf:"varint,2,opt,name=current_user_id,json=currentUserId,proto3" json:"current_user_id,omitempty"`
|
||||||
VerifierUserId int32 `protobuf:"varint,3,opt,name=verifier_user_id,json=verifierUserId,proto3" json:"verifier_user_id,omitempty"`
|
VerifierUserId int32 `protobuf:"varint,3,opt,name=verifier_user_id,json=verifierUserId,proto3" json:"verifier_user_id,omitempty"`
|
||||||
|
// Credential-sensitive write value. Implementations must not log this field
|
||||||
|
// unless an explicit redacted value-logging path is enabled.
|
||||||
Value *MxValue `protobuf:"bytes,4,opt,name=value,proto3" json:"value,omitempty"`
|
Value *MxValue `protobuf:"bytes,4,opt,name=value,proto3" json:"value,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
@@ -3914,6 +3916,8 @@ type WriteSecured2BulkEntry struct {
|
|||||||
ItemHandle int32 `protobuf:"varint,1,opt,name=item_handle,json=itemHandle,proto3" json:"item_handle,omitempty"`
|
ItemHandle int32 `protobuf:"varint,1,opt,name=item_handle,json=itemHandle,proto3" json:"item_handle,omitempty"`
|
||||||
CurrentUserId int32 `protobuf:"varint,2,opt,name=current_user_id,json=currentUserId,proto3" json:"current_user_id,omitempty"`
|
CurrentUserId int32 `protobuf:"varint,2,opt,name=current_user_id,json=currentUserId,proto3" json:"current_user_id,omitempty"`
|
||||||
VerifierUserId int32 `protobuf:"varint,3,opt,name=verifier_user_id,json=verifierUserId,proto3" json:"verifier_user_id,omitempty"`
|
VerifierUserId int32 `protobuf:"varint,3,opt,name=verifier_user_id,json=verifierUserId,proto3" json:"verifier_user_id,omitempty"`
|
||||||
|
// Credential-sensitive write value. Implementations must not log this field
|
||||||
|
// unless an explicit redacted value-logging path is enabled.
|
||||||
Value *MxValue `protobuf:"bytes,4,opt,name=value,proto3" json:"value,omitempty"`
|
Value *MxValue `protobuf:"bytes,4,opt,name=value,proto3" json:"value,omitempty"`
|
||||||
TimestampValue *MxValue `protobuf:"bytes,5,opt,name=timestamp_value,json=timestampValue,proto3" json:"timestamp_value,omitempty"`
|
TimestampValue *MxValue `protobuf:"bytes,5,opt,name=timestamp_value,json=timestampValue,proto3" json:"timestamp_value,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
@@ -3987,6 +3991,7 @@ func (x *WriteSecured2BulkEntry) GetTimestampValue() *MxValue {
|
|||||||
|
|
||||||
// Bulk Read — snapshot the current value for each requested tag. MXAccess COM
|
// Bulk Read — snapshot the current value for each requested tag. MXAccess COM
|
||||||
// has no synchronous Read; the worker implements ReadBulk as:
|
// has no synchronous Read; the worker implements ReadBulk as:
|
||||||
|
//
|
||||||
// - If the tag is already in the session's item registry AND that item is
|
// - If the tag is already in the session's item registry AND that item is
|
||||||
// currently advised AND the worker has a cached OnDataChange for it, the
|
// currently advised AND the worker has a cached OnDataChange for it, the
|
||||||
// reply returns the cached value WITHOUT modifying the existing
|
// reply returns the cached value WITHOUT modifying the existing
|
||||||
@@ -5245,9 +5250,11 @@ func (x *BulkSubscribeReply) GetResults() []*SubscribeResult {
|
|||||||
|
|
||||||
// Per-item result for the four bulk write families. `item_handle` mirrors the
|
// Per-item result for the four bulk write families. `item_handle` mirrors the
|
||||||
// request entry's item_handle so callers can correlate inputs to outputs even
|
// request entry's item_handle so callers can correlate inputs to outputs even
|
||||||
// when the gateway's tag-allowlist filter dropped some entries before reaching
|
// when the gateway's per-entry `IConstraintEnforcer.CheckWriteHandleAsync`
|
||||||
// the worker. Per-item failures populate `error_message` + `hresult` and never
|
// filter (see `MxAccessGatewayService.ReplaceWriteBulkEntries` and
|
||||||
// raise — callers iterate and inspect each entry.
|
// `docs/Authorization.md`) dropped some entries before reaching the worker.
|
||||||
|
// Per-item failures populate `error_message` + `hresult` and never raise —
|
||||||
|
// callers iterate and inspect each entry.
|
||||||
type BulkWriteResult struct {
|
type BulkWriteResult struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
||||||
@@ -5380,6 +5387,20 @@ func (x *BulkWriteReply) GetResults() []*BulkWriteResult {
|
|||||||
// an existing live subscription's last OnDataChange (the worker did not touch
|
// an existing live subscription's last OnDataChange (the worker did not touch
|
||||||
// the subscription); false when the worker took the AddItem + Advise + wait +
|
// the subscription); false when the worker took the AddItem + Advise + wait +
|
||||||
// UnAdvise + RemoveItem snapshot lifecycle itself.
|
// UnAdvise + RemoveItem snapshot lifecycle itself.
|
||||||
|
//
|
||||||
|
// On `was_successful = true`, `value`, `quality`, `source_timestamp`, and
|
||||||
|
// `statuses` carry the read data (from the cached subscription or the snapshot
|
||||||
|
// lifecycle, depending on `was_cached`) and `error_message` is empty. On
|
||||||
|
// `was_successful = false`, only `server_handle`, `tag_address`, `item_handle`
|
||||||
|
// (when allocated), `was_cached`, and `error_message` are populated; `value`,
|
||||||
|
// `quality`, `source_timestamp`, and `statuses` are left at their proto3
|
||||||
|
// defaults (null / 0 / null / empty) and must not be read as data — they are
|
||||||
|
// wire-indistinguishable from "value is null with quality bad" data and serve
|
||||||
|
// only as absent markers. ReadBulk has no `hresult` field by design (its
|
||||||
|
// outcomes are timeout / cache / lifecycle states, not MXAccess COM return
|
||||||
|
// codes — see `docs/DesignDecisions.md` "Bulk Command Family"). Per-tag
|
||||||
|
// failures populate `error_message` and never raise — callers iterate and
|
||||||
|
// inspect each entry.
|
||||||
type BulkReadResult struct {
|
type BulkReadResult struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
||||||
@@ -6528,7 +6549,6 @@ func (x *ActiveAlarmSnapshot) GetLimitValue() *MxValue {
|
|||||||
|
|
||||||
type AcknowledgeAlarmRequest struct {
|
type AcknowledgeAlarmRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
|
|
||||||
ClientCorrelationId string `protobuf:"bytes,2,opt,name=client_correlation_id,json=clientCorrelationId,proto3" json:"client_correlation_id,omitempty"`
|
ClientCorrelationId string `protobuf:"bytes,2,opt,name=client_correlation_id,json=clientCorrelationId,proto3" json:"client_correlation_id,omitempty"`
|
||||||
// Fully-qualified alarm reference matching OnAlarmTransitionEvent.alarm_full_reference.
|
// Fully-qualified alarm reference matching OnAlarmTransitionEvent.alarm_full_reference.
|
||||||
AlarmFullReference string `protobuf:"bytes,3,opt,name=alarm_full_reference,json=alarmFullReference,proto3" json:"alarm_full_reference,omitempty"`
|
AlarmFullReference string `protobuf:"bytes,3,opt,name=alarm_full_reference,json=alarmFullReference,proto3" json:"alarm_full_reference,omitempty"`
|
||||||
@@ -6571,13 +6591,6 @@ func (*AcknowledgeAlarmRequest) Descriptor() ([]byte, []int) {
|
|||||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{77}
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{77}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *AcknowledgeAlarmRequest) GetSessionId() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.SessionId
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *AcknowledgeAlarmRequest) GetClientCorrelationId() string {
|
func (x *AcknowledgeAlarmRequest) GetClientCorrelationId() string {
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.ClientCorrelationId
|
return x.ClientCorrelationId
|
||||||
@@ -6608,7 +6621,6 @@ func (x *AcknowledgeAlarmRequest) GetOperatorUser() string {
|
|||||||
|
|
||||||
type AcknowledgeAlarmReply struct {
|
type AcknowledgeAlarmReply struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
|
|
||||||
CorrelationId string `protobuf:"bytes,2,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"`
|
CorrelationId string `protobuf:"bytes,2,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"`
|
||||||
ProtocolStatus *ProtocolStatus `protobuf:"bytes,3,opt,name=protocol_status,json=protocolStatus,proto3" json:"protocol_status,omitempty"`
|
ProtocolStatus *ProtocolStatus `protobuf:"bytes,3,opt,name=protocol_status,json=protocolStatus,proto3" json:"protocol_status,omitempty"`
|
||||||
// Native ack return code echoed from the worker. The worker carries the
|
// Native ack return code echoed from the worker. The worker carries the
|
||||||
@@ -6659,13 +6671,6 @@ func (*AcknowledgeAlarmReply) Descriptor() ([]byte, []int) {
|
|||||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{78}
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{78}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *AcknowledgeAlarmReply) GetSessionId() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.SessionId
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *AcknowledgeAlarmReply) GetCorrelationId() string {
|
func (x *AcknowledgeAlarmReply) GetCorrelationId() string {
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.CorrelationId
|
return x.CorrelationId
|
||||||
@@ -6701,31 +6706,31 @@ func (x *AcknowledgeAlarmReply) GetDiagnosticMessage() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
type QueryActiveAlarmsRequest struct {
|
// Request to attach to the gateway's central alarm feed (StreamAlarms).
|
||||||
|
type StreamAlarmsRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
|
ClientCorrelationId string `protobuf:"bytes,1,opt,name=client_correlation_id,json=clientCorrelationId,proto3" json:"client_correlation_id,omitempty"`
|
||||||
ClientCorrelationId string `protobuf:"bytes,2,opt,name=client_correlation_id,json=clientCorrelationId,proto3" json:"client_correlation_id,omitempty"`
|
// Optional alarm-reference prefix scoping the feed to an equipment
|
||||||
// Optional alarm-reference prefix used to scope a partial ConditionRefresh
|
// sub-tree. Empty streams every active alarm.
|
||||||
// (e.g. equipment sub-tree). Empty means full refresh.
|
AlarmFilterPrefix string `protobuf:"bytes,2,opt,name=alarm_filter_prefix,json=alarmFilterPrefix,proto3" json:"alarm_filter_prefix,omitempty"`
|
||||||
AlarmFilterPrefix string `protobuf:"bytes,3,opt,name=alarm_filter_prefix,json=alarmFilterPrefix,proto3" json:"alarm_filter_prefix,omitempty"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *QueryActiveAlarmsRequest) Reset() {
|
func (x *StreamAlarmsRequest) Reset() {
|
||||||
*x = QueryActiveAlarmsRequest{}
|
*x = StreamAlarmsRequest{}
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[79]
|
mi := &file_mxaccess_gateway_proto_msgTypes[79]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *QueryActiveAlarmsRequest) String() string {
|
func (x *StreamAlarmsRequest) String() string {
|
||||||
return protoimpl.X.MessageStringOf(x)
|
return protoimpl.X.MessageStringOf(x)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*QueryActiveAlarmsRequest) ProtoMessage() {}
|
func (*StreamAlarmsRequest) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *QueryActiveAlarmsRequest) ProtoReflect() protoreflect.Message {
|
func (x *StreamAlarmsRequest) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[79]
|
mi := &file_mxaccess_gateway_proto_msgTypes[79]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
@@ -6737,32 +6742,130 @@ func (x *QueryActiveAlarmsRequest) ProtoReflect() protoreflect.Message {
|
|||||||
return mi.MessageOf(x)
|
return mi.MessageOf(x)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deprecated: Use QueryActiveAlarmsRequest.ProtoReflect.Descriptor instead.
|
// Deprecated: Use StreamAlarmsRequest.ProtoReflect.Descriptor instead.
|
||||||
func (*QueryActiveAlarmsRequest) Descriptor() ([]byte, []int) {
|
func (*StreamAlarmsRequest) Descriptor() ([]byte, []int) {
|
||||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{79}
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{79}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *QueryActiveAlarmsRequest) GetSessionId() string {
|
func (x *StreamAlarmsRequest) GetClientCorrelationId() string {
|
||||||
if x != nil {
|
|
||||||
return x.SessionId
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *QueryActiveAlarmsRequest) GetClientCorrelationId() string {
|
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.ClientCorrelationId
|
return x.ClientCorrelationId
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *QueryActiveAlarmsRequest) GetAlarmFilterPrefix() string {
|
func (x *StreamAlarmsRequest) GetAlarmFilterPrefix() string {
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.AlarmFilterPrefix
|
return x.AlarmFilterPrefix
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One message on the StreamAlarms feed. The stream opens with one
|
||||||
|
// `active_alarm` per currently-active alarm, then a single
|
||||||
|
// `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
type AlarmFeedMessage struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// Types that are valid to be assigned to Payload:
|
||||||
|
//
|
||||||
|
// *AlarmFeedMessage_ActiveAlarm
|
||||||
|
// *AlarmFeedMessage_SnapshotComplete
|
||||||
|
// *AlarmFeedMessage_Transition
|
||||||
|
Payload isAlarmFeedMessage_Payload `protobuf_oneof:"payload"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AlarmFeedMessage) Reset() {
|
||||||
|
*x = AlarmFeedMessage{}
|
||||||
|
mi := &file_mxaccess_gateway_proto_msgTypes[80]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AlarmFeedMessage) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*AlarmFeedMessage) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *AlarmFeedMessage) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mxaccess_gateway_proto_msgTypes[80]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use AlarmFeedMessage.ProtoReflect.Descriptor instead.
|
||||||
|
func (*AlarmFeedMessage) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{80}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AlarmFeedMessage) GetPayload() isAlarmFeedMessage_Payload {
|
||||||
|
if x != nil {
|
||||||
|
return x.Payload
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AlarmFeedMessage) GetActiveAlarm() *ActiveAlarmSnapshot {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Payload.(*AlarmFeedMessage_ActiveAlarm); ok {
|
||||||
|
return x.ActiveAlarm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AlarmFeedMessage) GetSnapshotComplete() bool {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Payload.(*AlarmFeedMessage_SnapshotComplete); ok {
|
||||||
|
return x.SnapshotComplete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AlarmFeedMessage) GetTransition() *OnAlarmTransitionEvent {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Payload.(*AlarmFeedMessage_Transition); ok {
|
||||||
|
return x.Transition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type isAlarmFeedMessage_Payload interface {
|
||||||
|
isAlarmFeedMessage_Payload()
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlarmFeedMessage_ActiveAlarm struct {
|
||||||
|
// Part of the initial active-alarm snapshot (ConditionRefresh).
|
||||||
|
ActiveAlarm *ActiveAlarmSnapshot `protobuf:"bytes,1,opt,name=active_alarm,json=activeAlarm,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlarmFeedMessage_SnapshotComplete struct {
|
||||||
|
// Sentinel: the initial snapshot is fully delivered and `transition`
|
||||||
|
// messages follow. Always true when present.
|
||||||
|
SnapshotComplete bool `protobuf:"varint,2,opt,name=snapshot_complete,json=snapshotComplete,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlarmFeedMessage_Transition struct {
|
||||||
|
// A live alarm state change (raise / acknowledge / clear).
|
||||||
|
Transition *OnAlarmTransitionEvent `protobuf:"bytes,3,opt,name=transition,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*AlarmFeedMessage_ActiveAlarm) isAlarmFeedMessage_Payload() {}
|
||||||
|
|
||||||
|
func (*AlarmFeedMessage_SnapshotComplete) isAlarmFeedMessage_Payload() {}
|
||||||
|
|
||||||
|
func (*AlarmFeedMessage_Transition) isAlarmFeedMessage_Payload() {}
|
||||||
|
|
||||||
type MxStatusProxy struct {
|
type MxStatusProxy struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
|
// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
|
||||||
@@ -6787,7 +6890,7 @@ type MxStatusProxy struct {
|
|||||||
|
|
||||||
func (x *MxStatusProxy) Reset() {
|
func (x *MxStatusProxy) Reset() {
|
||||||
*x = MxStatusProxy{}
|
*x = MxStatusProxy{}
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[80]
|
mi := &file_mxaccess_gateway_proto_msgTypes[81]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -6799,7 +6902,7 @@ func (x *MxStatusProxy) String() string {
|
|||||||
func (*MxStatusProxy) ProtoMessage() {}
|
func (*MxStatusProxy) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *MxStatusProxy) ProtoReflect() protoreflect.Message {
|
func (x *MxStatusProxy) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[80]
|
mi := &file_mxaccess_gateway_proto_msgTypes[81]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -6812,7 +6915,7 @@ func (x *MxStatusProxy) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use MxStatusProxy.ProtoReflect.Descriptor instead.
|
// Deprecated: Use MxStatusProxy.ProtoReflect.Descriptor instead.
|
||||||
func (*MxStatusProxy) Descriptor() ([]byte, []int) {
|
func (*MxStatusProxy) Descriptor() ([]byte, []int) {
|
||||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{80}
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{81}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *MxStatusProxy) GetSuccess() int32 {
|
func (x *MxStatusProxy) GetSuccess() int32 {
|
||||||
@@ -6889,7 +6992,7 @@ type MxValue struct {
|
|||||||
|
|
||||||
func (x *MxValue) Reset() {
|
func (x *MxValue) Reset() {
|
||||||
*x = MxValue{}
|
*x = MxValue{}
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[81]
|
mi := &file_mxaccess_gateway_proto_msgTypes[82]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -6901,7 +7004,7 @@ func (x *MxValue) String() string {
|
|||||||
func (*MxValue) ProtoMessage() {}
|
func (*MxValue) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *MxValue) ProtoReflect() protoreflect.Message {
|
func (x *MxValue) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[81]
|
mi := &file_mxaccess_gateway_proto_msgTypes[82]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -6914,7 +7017,7 @@ func (x *MxValue) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use MxValue.ProtoReflect.Descriptor instead.
|
// Deprecated: Use MxValue.ProtoReflect.Descriptor instead.
|
||||||
func (*MxValue) Descriptor() ([]byte, []int) {
|
func (*MxValue) Descriptor() ([]byte, []int) {
|
||||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{81}
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{82}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *MxValue) GetDataType() MxDataType {
|
func (x *MxValue) GetDataType() MxDataType {
|
||||||
@@ -7122,7 +7225,7 @@ type MxArray struct {
|
|||||||
|
|
||||||
func (x *MxArray) Reset() {
|
func (x *MxArray) Reset() {
|
||||||
*x = MxArray{}
|
*x = MxArray{}
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[82]
|
mi := &file_mxaccess_gateway_proto_msgTypes[83]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -7134,7 +7237,7 @@ func (x *MxArray) String() string {
|
|||||||
func (*MxArray) ProtoMessage() {}
|
func (*MxArray) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *MxArray) ProtoReflect() protoreflect.Message {
|
func (x *MxArray) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[82]
|
mi := &file_mxaccess_gateway_proto_msgTypes[83]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -7147,7 +7250,7 @@ func (x *MxArray) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use MxArray.ProtoReflect.Descriptor instead.
|
// Deprecated: Use MxArray.ProtoReflect.Descriptor instead.
|
||||||
func (*MxArray) Descriptor() ([]byte, []int) {
|
func (*MxArray) Descriptor() ([]byte, []int) {
|
||||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{82}
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{83}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *MxArray) GetElementDataType() MxDataType {
|
func (x *MxArray) GetElementDataType() MxDataType {
|
||||||
@@ -7325,7 +7428,7 @@ type BoolArray struct {
|
|||||||
|
|
||||||
func (x *BoolArray) Reset() {
|
func (x *BoolArray) Reset() {
|
||||||
*x = BoolArray{}
|
*x = BoolArray{}
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[83]
|
mi := &file_mxaccess_gateway_proto_msgTypes[84]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -7337,7 +7440,7 @@ func (x *BoolArray) String() string {
|
|||||||
func (*BoolArray) ProtoMessage() {}
|
func (*BoolArray) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *BoolArray) ProtoReflect() protoreflect.Message {
|
func (x *BoolArray) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[83]
|
mi := &file_mxaccess_gateway_proto_msgTypes[84]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -7350,7 +7453,7 @@ func (x *BoolArray) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use BoolArray.ProtoReflect.Descriptor instead.
|
// Deprecated: Use BoolArray.ProtoReflect.Descriptor instead.
|
||||||
func (*BoolArray) Descriptor() ([]byte, []int) {
|
func (*BoolArray) Descriptor() ([]byte, []int) {
|
||||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{83}
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{84}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *BoolArray) GetValues() []bool {
|
func (x *BoolArray) GetValues() []bool {
|
||||||
@@ -7369,7 +7472,7 @@ type Int32Array struct {
|
|||||||
|
|
||||||
func (x *Int32Array) Reset() {
|
func (x *Int32Array) Reset() {
|
||||||
*x = Int32Array{}
|
*x = Int32Array{}
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[84]
|
mi := &file_mxaccess_gateway_proto_msgTypes[85]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -7381,7 +7484,7 @@ func (x *Int32Array) String() string {
|
|||||||
func (*Int32Array) ProtoMessage() {}
|
func (*Int32Array) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *Int32Array) ProtoReflect() protoreflect.Message {
|
func (x *Int32Array) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[84]
|
mi := &file_mxaccess_gateway_proto_msgTypes[85]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -7394,7 +7497,7 @@ func (x *Int32Array) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use Int32Array.ProtoReflect.Descriptor instead.
|
// Deprecated: Use Int32Array.ProtoReflect.Descriptor instead.
|
||||||
func (*Int32Array) Descriptor() ([]byte, []int) {
|
func (*Int32Array) Descriptor() ([]byte, []int) {
|
||||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{84}
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{85}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Int32Array) GetValues() []int32 {
|
func (x *Int32Array) GetValues() []int32 {
|
||||||
@@ -7413,7 +7516,7 @@ type Int64Array struct {
|
|||||||
|
|
||||||
func (x *Int64Array) Reset() {
|
func (x *Int64Array) Reset() {
|
||||||
*x = Int64Array{}
|
*x = Int64Array{}
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[85]
|
mi := &file_mxaccess_gateway_proto_msgTypes[86]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -7425,7 +7528,7 @@ func (x *Int64Array) String() string {
|
|||||||
func (*Int64Array) ProtoMessage() {}
|
func (*Int64Array) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *Int64Array) ProtoReflect() protoreflect.Message {
|
func (x *Int64Array) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[85]
|
mi := &file_mxaccess_gateway_proto_msgTypes[86]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -7438,7 +7541,7 @@ func (x *Int64Array) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use Int64Array.ProtoReflect.Descriptor instead.
|
// Deprecated: Use Int64Array.ProtoReflect.Descriptor instead.
|
||||||
func (*Int64Array) Descriptor() ([]byte, []int) {
|
func (*Int64Array) Descriptor() ([]byte, []int) {
|
||||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{85}
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{86}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Int64Array) GetValues() []int64 {
|
func (x *Int64Array) GetValues() []int64 {
|
||||||
@@ -7457,7 +7560,7 @@ type FloatArray struct {
|
|||||||
|
|
||||||
func (x *FloatArray) Reset() {
|
func (x *FloatArray) Reset() {
|
||||||
*x = FloatArray{}
|
*x = FloatArray{}
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[86]
|
mi := &file_mxaccess_gateway_proto_msgTypes[87]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -7469,7 +7572,7 @@ func (x *FloatArray) String() string {
|
|||||||
func (*FloatArray) ProtoMessage() {}
|
func (*FloatArray) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *FloatArray) ProtoReflect() protoreflect.Message {
|
func (x *FloatArray) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[86]
|
mi := &file_mxaccess_gateway_proto_msgTypes[87]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -7482,7 +7585,7 @@ func (x *FloatArray) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use FloatArray.ProtoReflect.Descriptor instead.
|
// Deprecated: Use FloatArray.ProtoReflect.Descriptor instead.
|
||||||
func (*FloatArray) Descriptor() ([]byte, []int) {
|
func (*FloatArray) Descriptor() ([]byte, []int) {
|
||||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{86}
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{87}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *FloatArray) GetValues() []float32 {
|
func (x *FloatArray) GetValues() []float32 {
|
||||||
@@ -7501,7 +7604,7 @@ type DoubleArray struct {
|
|||||||
|
|
||||||
func (x *DoubleArray) Reset() {
|
func (x *DoubleArray) Reset() {
|
||||||
*x = DoubleArray{}
|
*x = DoubleArray{}
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[87]
|
mi := &file_mxaccess_gateway_proto_msgTypes[88]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -7513,7 +7616,7 @@ func (x *DoubleArray) String() string {
|
|||||||
func (*DoubleArray) ProtoMessage() {}
|
func (*DoubleArray) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *DoubleArray) ProtoReflect() protoreflect.Message {
|
func (x *DoubleArray) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[87]
|
mi := &file_mxaccess_gateway_proto_msgTypes[88]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -7526,7 +7629,7 @@ func (x *DoubleArray) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use DoubleArray.ProtoReflect.Descriptor instead.
|
// Deprecated: Use DoubleArray.ProtoReflect.Descriptor instead.
|
||||||
func (*DoubleArray) Descriptor() ([]byte, []int) {
|
func (*DoubleArray) Descriptor() ([]byte, []int) {
|
||||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{87}
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{88}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *DoubleArray) GetValues() []float64 {
|
func (x *DoubleArray) GetValues() []float64 {
|
||||||
@@ -7545,7 +7648,7 @@ type StringArray struct {
|
|||||||
|
|
||||||
func (x *StringArray) Reset() {
|
func (x *StringArray) Reset() {
|
||||||
*x = StringArray{}
|
*x = StringArray{}
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[88]
|
mi := &file_mxaccess_gateway_proto_msgTypes[89]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -7557,7 +7660,7 @@ func (x *StringArray) String() string {
|
|||||||
func (*StringArray) ProtoMessage() {}
|
func (*StringArray) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *StringArray) ProtoReflect() protoreflect.Message {
|
func (x *StringArray) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[88]
|
mi := &file_mxaccess_gateway_proto_msgTypes[89]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -7570,7 +7673,7 @@ func (x *StringArray) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use StringArray.ProtoReflect.Descriptor instead.
|
// Deprecated: Use StringArray.ProtoReflect.Descriptor instead.
|
||||||
func (*StringArray) Descriptor() ([]byte, []int) {
|
func (*StringArray) Descriptor() ([]byte, []int) {
|
||||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{88}
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{89}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *StringArray) GetValues() []string {
|
func (x *StringArray) GetValues() []string {
|
||||||
@@ -7589,7 +7692,7 @@ type TimestampArray struct {
|
|||||||
|
|
||||||
func (x *TimestampArray) Reset() {
|
func (x *TimestampArray) Reset() {
|
||||||
*x = TimestampArray{}
|
*x = TimestampArray{}
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[89]
|
mi := &file_mxaccess_gateway_proto_msgTypes[90]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -7601,7 +7704,7 @@ func (x *TimestampArray) String() string {
|
|||||||
func (*TimestampArray) ProtoMessage() {}
|
func (*TimestampArray) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *TimestampArray) ProtoReflect() protoreflect.Message {
|
func (x *TimestampArray) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[89]
|
mi := &file_mxaccess_gateway_proto_msgTypes[90]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -7614,7 +7717,7 @@ func (x *TimestampArray) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use TimestampArray.ProtoReflect.Descriptor instead.
|
// Deprecated: Use TimestampArray.ProtoReflect.Descriptor instead.
|
||||||
func (*TimestampArray) Descriptor() ([]byte, []int) {
|
func (*TimestampArray) Descriptor() ([]byte, []int) {
|
||||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{89}
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{90}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *TimestampArray) GetValues() []*timestamppb.Timestamp {
|
func (x *TimestampArray) GetValues() []*timestamppb.Timestamp {
|
||||||
@@ -7633,7 +7736,7 @@ type RawArray struct {
|
|||||||
|
|
||||||
func (x *RawArray) Reset() {
|
func (x *RawArray) Reset() {
|
||||||
*x = RawArray{}
|
*x = RawArray{}
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[90]
|
mi := &file_mxaccess_gateway_proto_msgTypes[91]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -7645,7 +7748,7 @@ func (x *RawArray) String() string {
|
|||||||
func (*RawArray) ProtoMessage() {}
|
func (*RawArray) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *RawArray) ProtoReflect() protoreflect.Message {
|
func (x *RawArray) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[90]
|
mi := &file_mxaccess_gateway_proto_msgTypes[91]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -7658,7 +7761,7 @@ func (x *RawArray) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use RawArray.ProtoReflect.Descriptor instead.
|
// Deprecated: Use RawArray.ProtoReflect.Descriptor instead.
|
||||||
func (*RawArray) Descriptor() ([]byte, []int) {
|
func (*RawArray) Descriptor() ([]byte, []int) {
|
||||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{90}
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{91}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *RawArray) GetValues() [][]byte {
|
func (x *RawArray) GetValues() [][]byte {
|
||||||
@@ -7678,7 +7781,7 @@ type ProtocolStatus struct {
|
|||||||
|
|
||||||
func (x *ProtocolStatus) Reset() {
|
func (x *ProtocolStatus) Reset() {
|
||||||
*x = ProtocolStatus{}
|
*x = ProtocolStatus{}
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[91]
|
mi := &file_mxaccess_gateway_proto_msgTypes[92]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -7690,7 +7793,7 @@ func (x *ProtocolStatus) String() string {
|
|||||||
func (*ProtocolStatus) ProtoMessage() {}
|
func (*ProtocolStatus) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *ProtocolStatus) ProtoReflect() protoreflect.Message {
|
func (x *ProtocolStatus) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mxaccess_gateway_proto_msgTypes[91]
|
mi := &file_mxaccess_gateway_proto_msgTypes[92]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -7703,7 +7806,7 @@ func (x *ProtocolStatus) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use ProtocolStatus.ProtoReflect.Descriptor instead.
|
// Deprecated: Use ProtocolStatus.ProtoReflect.Descriptor instead.
|
||||||
func (*ProtocolStatus) Descriptor() ([]byte, []int) {
|
func (*ProtocolStatus) Descriptor() ([]byte, []int) {
|
||||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{91}
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{92}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *ProtocolStatus) GetCode() ProtocolStatusCode {
|
func (x *ProtocolStatus) GetCode() ProtocolStatusCode {
|
||||||
@@ -8155,29 +8258,32 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
|
|||||||
"\x10operator_comment\x18\v \x01(\tR\x0foperatorComment\x12A\n" +
|
"\x10operator_comment\x18\v \x01(\tR\x0foperatorComment\x12A\n" +
|
||||||
"\rcurrent_value\x18\f \x01(\v2\x1c.mxaccess_gateway.v1.MxValueR\fcurrentValue\x12=\n" +
|
"\rcurrent_value\x18\f \x01(\v2\x1c.mxaccess_gateway.v1.MxValueR\fcurrentValue\x12=\n" +
|
||||||
"\vlimit_value\x18\r \x01(\v2\x1c.mxaccess_gateway.v1.MxValueR\n" +
|
"\vlimit_value\x18\r \x01(\v2\x1c.mxaccess_gateway.v1.MxValueR\n" +
|
||||||
"limitValue\"\xdd\x01\n" +
|
"limitValue\"\xd0\x01\n" +
|
||||||
"\x17AcknowledgeAlarmRequest\x12\x1d\n" +
|
"\x17AcknowledgeAlarmRequest\x122\n" +
|
||||||
"\n" +
|
|
||||||
"session_id\x18\x01 \x01(\tR\tsessionId\x122\n" +
|
|
||||||
"\x15client_correlation_id\x18\x02 \x01(\tR\x13clientCorrelationId\x120\n" +
|
"\x15client_correlation_id\x18\x02 \x01(\tR\x13clientCorrelationId\x120\n" +
|
||||||
"\x14alarm_full_reference\x18\x03 \x01(\tR\x12alarmFullReference\x12\x18\n" +
|
"\x14alarm_full_reference\x18\x03 \x01(\tR\x12alarmFullReference\x12\x18\n" +
|
||||||
"\acomment\x18\x04 \x01(\tR\acomment\x12#\n" +
|
"\acomment\x18\x04 \x01(\tR\acomment\x12#\n" +
|
||||||
"\roperator_user\x18\x05 \x01(\tR\foperatorUser\"\xc1\x02\n" +
|
"\roperator_user\x18\x05 \x01(\tR\foperatorUserJ\x04\b\x01\x10\x02R\n" +
|
||||||
"\x15AcknowledgeAlarmReply\x12\x1d\n" +
|
"session_id\"\xb4\x02\n" +
|
||||||
"\n" +
|
"\x15AcknowledgeAlarmReply\x12%\n" +
|
||||||
"session_id\x18\x01 \x01(\tR\tsessionId\x12%\n" +
|
|
||||||
"\x0ecorrelation_id\x18\x02 \x01(\tR\rcorrelationId\x12L\n" +
|
"\x0ecorrelation_id\x18\x02 \x01(\tR\rcorrelationId\x12L\n" +
|
||||||
"\x0fprotocol_status\x18\x03 \x01(\v2#.mxaccess_gateway.v1.ProtocolStatusR\x0eprotocolStatus\x12\x1d\n" +
|
"\x0fprotocol_status\x18\x03 \x01(\v2#.mxaccess_gateway.v1.ProtocolStatusR\x0eprotocolStatus\x12\x1d\n" +
|
||||||
"\ahresult\x18\x04 \x01(\x05H\x00R\ahresult\x88\x01\x01\x12:\n" +
|
"\ahresult\x18\x04 \x01(\x05H\x00R\ahresult\x88\x01\x01\x12:\n" +
|
||||||
"\x06status\x18\x05 \x01(\v2\".mxaccess_gateway.v1.MxStatusProxyR\x06status\x12-\n" +
|
"\x06status\x18\x05 \x01(\v2\".mxaccess_gateway.v1.MxStatusProxyR\x06status\x12-\n" +
|
||||||
"\x12diagnostic_message\x18\x06 \x01(\tR\x11diagnosticMessageB\n" +
|
"\x12diagnostic_message\x18\x06 \x01(\tR\x11diagnosticMessageB\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\b_hresult\"\x9d\x01\n" +
|
"\b_hresultJ\x04\b\x01\x10\x02R\n" +
|
||||||
"\x18QueryActiveAlarmsRequest\x12\x1d\n" +
|
"session_id\"y\n" +
|
||||||
|
"\x13StreamAlarmsRequest\x122\n" +
|
||||||
|
"\x15client_correlation_id\x18\x01 \x01(\tR\x13clientCorrelationId\x12.\n" +
|
||||||
|
"\x13alarm_filter_prefix\x18\x02 \x01(\tR\x11alarmFilterPrefix\"\xea\x01\n" +
|
||||||
|
"\x10AlarmFeedMessage\x12M\n" +
|
||||||
|
"\factive_alarm\x18\x01 \x01(\v2(.mxaccess_gateway.v1.ActiveAlarmSnapshotH\x00R\vactiveAlarm\x12-\n" +
|
||||||
|
"\x11snapshot_complete\x18\x02 \x01(\bH\x00R\x10snapshotComplete\x12M\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"session_id\x18\x01 \x01(\tR\tsessionId\x122\n" +
|
"transition\x18\x03 \x01(\v2+.mxaccess_gateway.v1.OnAlarmTransitionEventH\x00R\n" +
|
||||||
"\x15client_correlation_id\x18\x02 \x01(\tR\x13clientCorrelationId\x12.\n" +
|
"transitionB\t\n" +
|
||||||
"\x13alarm_filter_prefix\x18\x03 \x01(\tR\x11alarmFilterPrefix\"\xbe\x02\n" +
|
"\apayload\"\xbe\x02\n" +
|
||||||
"\rMxStatusProxy\x12\x18\n" +
|
"\rMxStatusProxy\x12\x18\n" +
|
||||||
"\asuccess\x18\x01 \x01(\x05R\asuccess\x12A\n" +
|
"\asuccess\x18\x01 \x01(\x05R\asuccess\x12A\n" +
|
||||||
"\bcategory\x18\x02 \x01(\x0e2%.mxaccess_gateway.v1.MxStatusCategoryR\bcategory\x12D\n" +
|
"\bcategory\x18\x02 \x01(\x0e2%.mxaccess_gateway.v1.MxStatusCategoryR\bcategory\x12D\n" +
|
||||||
@@ -8377,14 +8483,14 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
|
|||||||
"\x13SESSION_STATE_READY\x10\x06\x12\x19\n" +
|
"\x13SESSION_STATE_READY\x10\x06\x12\x19\n" +
|
||||||
"\x15SESSION_STATE_CLOSING\x10\a\x12\x18\n" +
|
"\x15SESSION_STATE_CLOSING\x10\a\x12\x18\n" +
|
||||||
"\x14SESSION_STATE_CLOSED\x10\b\x12\x19\n" +
|
"\x14SESSION_STATE_CLOSED\x10\b\x12\x19\n" +
|
||||||
"\x15SESSION_STATE_FAULTED\x10\t2\xe0\x04\n" +
|
"\x15SESSION_STATE_FAULTED\x10\t2\xd3\x04\n" +
|
||||||
"\x0fMxAccessGateway\x12]\n" +
|
"\x0fMxAccessGateway\x12]\n" +
|
||||||
"\vOpenSession\x12'.mxaccess_gateway.v1.OpenSessionRequest\x1a%.mxaccess_gateway.v1.OpenSessionReply\x12`\n" +
|
"\vOpenSession\x12'.mxaccess_gateway.v1.OpenSessionRequest\x1a%.mxaccess_gateway.v1.OpenSessionReply\x12`\n" +
|
||||||
"\fCloseSession\x12(.mxaccess_gateway.v1.CloseSessionRequest\x1a&.mxaccess_gateway.v1.CloseSessionReply\x12T\n" +
|
"\fCloseSession\x12(.mxaccess_gateway.v1.CloseSessionRequest\x1a&.mxaccess_gateway.v1.CloseSessionReply\x12T\n" +
|
||||||
"\x06Invoke\x12%.mxaccess_gateway.v1.MxCommandRequest\x1a#.mxaccess_gateway.v1.MxCommandReply\x12X\n" +
|
"\x06Invoke\x12%.mxaccess_gateway.v1.MxCommandRequest\x1a#.mxaccess_gateway.v1.MxCommandReply\x12X\n" +
|
||||||
"\fStreamEvents\x12(.mxaccess_gateway.v1.StreamEventsRequest\x1a\x1c.mxaccess_gateway.v1.MxEvent0\x01\x12l\n" +
|
"\fStreamEvents\x12(.mxaccess_gateway.v1.StreamEventsRequest\x1a\x1c.mxaccess_gateway.v1.MxEvent0\x01\x12l\n" +
|
||||||
"\x10AcknowledgeAlarm\x12,.mxaccess_gateway.v1.AcknowledgeAlarmRequest\x1a*.mxaccess_gateway.v1.AcknowledgeAlarmReply\x12n\n" +
|
"\x10AcknowledgeAlarm\x12,.mxaccess_gateway.v1.AcknowledgeAlarmRequest\x1a*.mxaccess_gateway.v1.AcknowledgeAlarmReply\x12a\n" +
|
||||||
"\x11QueryActiveAlarms\x12-.mxaccess_gateway.v1.QueryActiveAlarmsRequest\x1a(.mxaccess_gateway.v1.ActiveAlarmSnapshot0\x01B\x1c\xaa\x02\x19MxGateway.Contracts.Protob\x06proto3"
|
"\fStreamAlarms\x12(.mxaccess_gateway.v1.StreamAlarmsRequest\x1a%.mxaccess_gateway.v1.AlarmFeedMessage0\x01B\x1c\xaa\x02\x19MxGateway.Contracts.Protob\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_mxaccess_gateway_proto_rawDescOnce sync.Once
|
file_mxaccess_gateway_proto_rawDescOnce sync.Once
|
||||||
@@ -8399,7 +8505,7 @@ func file_mxaccess_gateway_proto_rawDescGZIP() []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var file_mxaccess_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 9)
|
var file_mxaccess_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 9)
|
||||||
var file_mxaccess_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 92)
|
var file_mxaccess_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 93)
|
||||||
var file_mxaccess_gateway_proto_goTypes = []any{
|
var file_mxaccess_gateway_proto_goTypes = []any{
|
||||||
(MxCommandKind)(0), // 0: mxaccess_gateway.v1.MxCommandKind
|
(MxCommandKind)(0), // 0: mxaccess_gateway.v1.MxCommandKind
|
||||||
(MxEventFamily)(0), // 1: mxaccess_gateway.v1.MxEventFamily
|
(MxEventFamily)(0), // 1: mxaccess_gateway.v1.MxEventFamily
|
||||||
@@ -8489,28 +8595,29 @@ var file_mxaccess_gateway_proto_goTypes = []any{
|
|||||||
(*ActiveAlarmSnapshot)(nil), // 85: mxaccess_gateway.v1.ActiveAlarmSnapshot
|
(*ActiveAlarmSnapshot)(nil), // 85: mxaccess_gateway.v1.ActiveAlarmSnapshot
|
||||||
(*AcknowledgeAlarmRequest)(nil), // 86: mxaccess_gateway.v1.AcknowledgeAlarmRequest
|
(*AcknowledgeAlarmRequest)(nil), // 86: mxaccess_gateway.v1.AcknowledgeAlarmRequest
|
||||||
(*AcknowledgeAlarmReply)(nil), // 87: mxaccess_gateway.v1.AcknowledgeAlarmReply
|
(*AcknowledgeAlarmReply)(nil), // 87: mxaccess_gateway.v1.AcknowledgeAlarmReply
|
||||||
(*QueryActiveAlarmsRequest)(nil), // 88: mxaccess_gateway.v1.QueryActiveAlarmsRequest
|
(*StreamAlarmsRequest)(nil), // 88: mxaccess_gateway.v1.StreamAlarmsRequest
|
||||||
(*MxStatusProxy)(nil), // 89: mxaccess_gateway.v1.MxStatusProxy
|
(*AlarmFeedMessage)(nil), // 89: mxaccess_gateway.v1.AlarmFeedMessage
|
||||||
(*MxValue)(nil), // 90: mxaccess_gateway.v1.MxValue
|
(*MxStatusProxy)(nil), // 90: mxaccess_gateway.v1.MxStatusProxy
|
||||||
(*MxArray)(nil), // 91: mxaccess_gateway.v1.MxArray
|
(*MxValue)(nil), // 91: mxaccess_gateway.v1.MxValue
|
||||||
(*BoolArray)(nil), // 92: mxaccess_gateway.v1.BoolArray
|
(*MxArray)(nil), // 92: mxaccess_gateway.v1.MxArray
|
||||||
(*Int32Array)(nil), // 93: mxaccess_gateway.v1.Int32Array
|
(*BoolArray)(nil), // 93: mxaccess_gateway.v1.BoolArray
|
||||||
(*Int64Array)(nil), // 94: mxaccess_gateway.v1.Int64Array
|
(*Int32Array)(nil), // 94: mxaccess_gateway.v1.Int32Array
|
||||||
(*FloatArray)(nil), // 95: mxaccess_gateway.v1.FloatArray
|
(*Int64Array)(nil), // 95: mxaccess_gateway.v1.Int64Array
|
||||||
(*DoubleArray)(nil), // 96: mxaccess_gateway.v1.DoubleArray
|
(*FloatArray)(nil), // 96: mxaccess_gateway.v1.FloatArray
|
||||||
(*StringArray)(nil), // 97: mxaccess_gateway.v1.StringArray
|
(*DoubleArray)(nil), // 97: mxaccess_gateway.v1.DoubleArray
|
||||||
(*TimestampArray)(nil), // 98: mxaccess_gateway.v1.TimestampArray
|
(*StringArray)(nil), // 98: mxaccess_gateway.v1.StringArray
|
||||||
(*RawArray)(nil), // 99: mxaccess_gateway.v1.RawArray
|
(*TimestampArray)(nil), // 99: mxaccess_gateway.v1.TimestampArray
|
||||||
(*ProtocolStatus)(nil), // 100: mxaccess_gateway.v1.ProtocolStatus
|
(*RawArray)(nil), // 100: mxaccess_gateway.v1.RawArray
|
||||||
(*durationpb.Duration)(nil), // 101: google.protobuf.Duration
|
(*ProtocolStatus)(nil), // 101: mxaccess_gateway.v1.ProtocolStatus
|
||||||
(*timestamppb.Timestamp)(nil), // 102: google.protobuf.Timestamp
|
(*durationpb.Duration)(nil), // 102: google.protobuf.Duration
|
||||||
|
(*timestamppb.Timestamp)(nil), // 103: google.protobuf.Timestamp
|
||||||
}
|
}
|
||||||
var file_mxaccess_gateway_proto_depIdxs = []int32{
|
var file_mxaccess_gateway_proto_depIdxs = []int32{
|
||||||
101, // 0: mxaccess_gateway.v1.OpenSessionRequest.command_timeout:type_name -> google.protobuf.Duration
|
102, // 0: mxaccess_gateway.v1.OpenSessionRequest.command_timeout:type_name -> google.protobuf.Duration
|
||||||
101, // 1: mxaccess_gateway.v1.OpenSessionReply.default_command_timeout:type_name -> google.protobuf.Duration
|
102, // 1: mxaccess_gateway.v1.OpenSessionReply.default_command_timeout:type_name -> google.protobuf.Duration
|
||||||
100, // 2: mxaccess_gateway.v1.OpenSessionReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus
|
101, // 2: mxaccess_gateway.v1.OpenSessionReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus
|
||||||
8, // 3: mxaccess_gateway.v1.CloseSessionReply.final_state:type_name -> mxaccess_gateway.v1.SessionState
|
8, // 3: mxaccess_gateway.v1.CloseSessionReply.final_state:type_name -> mxaccess_gateway.v1.SessionState
|
||||||
100, // 4: mxaccess_gateway.v1.CloseSessionReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus
|
101, // 4: mxaccess_gateway.v1.CloseSessionReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus
|
||||||
15, // 5: mxaccess_gateway.v1.MxCommandRequest.command:type_name -> mxaccess_gateway.v1.MxCommand
|
15, // 5: mxaccess_gateway.v1.MxCommandRequest.command:type_name -> mxaccess_gateway.v1.MxCommand
|
||||||
0, // 6: mxaccess_gateway.v1.MxCommand.kind:type_name -> mxaccess_gateway.v1.MxCommandKind
|
0, // 6: mxaccess_gateway.v1.MxCommand.kind:type_name -> mxaccess_gateway.v1.MxCommandKind
|
||||||
16, // 7: mxaccess_gateway.v1.MxCommand.register:type_name -> mxaccess_gateway.v1.RegisterCommand
|
16, // 7: mxaccess_gateway.v1.MxCommand.register:type_name -> mxaccess_gateway.v1.RegisterCommand
|
||||||
@@ -8552,27 +8659,27 @@ var file_mxaccess_gateway_proto_depIdxs = []int32{
|
|||||||
56, // 43: mxaccess_gateway.v1.MxCommand.get_worker_info:type_name -> mxaccess_gateway.v1.GetWorkerInfoCommand
|
56, // 43: mxaccess_gateway.v1.MxCommand.get_worker_info:type_name -> mxaccess_gateway.v1.GetWorkerInfoCommand
|
||||||
57, // 44: mxaccess_gateway.v1.MxCommand.drain_events:type_name -> mxaccess_gateway.v1.DrainEventsCommand
|
57, // 44: mxaccess_gateway.v1.MxCommand.drain_events:type_name -> mxaccess_gateway.v1.DrainEventsCommand
|
||||||
58, // 45: mxaccess_gateway.v1.MxCommand.shutdown_worker:type_name -> mxaccess_gateway.v1.ShutdownWorkerCommand
|
58, // 45: mxaccess_gateway.v1.MxCommand.shutdown_worker:type_name -> mxaccess_gateway.v1.ShutdownWorkerCommand
|
||||||
90, // 46: mxaccess_gateway.v1.WriteCommand.value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 46: mxaccess_gateway.v1.WriteCommand.value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
90, // 47: mxaccess_gateway.v1.Write2Command.value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 47: mxaccess_gateway.v1.Write2Command.value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
90, // 48: mxaccess_gateway.v1.Write2Command.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 48: mxaccess_gateway.v1.Write2Command.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
90, // 49: mxaccess_gateway.v1.WriteSecuredCommand.value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 49: mxaccess_gateway.v1.WriteSecuredCommand.value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
90, // 50: mxaccess_gateway.v1.WriteSecured2Command.value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 50: mxaccess_gateway.v1.WriteSecured2Command.value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
90, // 51: mxaccess_gateway.v1.WriteSecured2Command.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 51: mxaccess_gateway.v1.WriteSecured2Command.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
46, // 52: mxaccess_gateway.v1.WriteBulkCommand.entries:type_name -> mxaccess_gateway.v1.WriteBulkEntry
|
46, // 52: mxaccess_gateway.v1.WriteBulkCommand.entries:type_name -> mxaccess_gateway.v1.WriteBulkEntry
|
||||||
90, // 53: mxaccess_gateway.v1.WriteBulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 53: mxaccess_gateway.v1.WriteBulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
48, // 54: mxaccess_gateway.v1.Write2BulkCommand.entries:type_name -> mxaccess_gateway.v1.Write2BulkEntry
|
48, // 54: mxaccess_gateway.v1.Write2BulkCommand.entries:type_name -> mxaccess_gateway.v1.Write2BulkEntry
|
||||||
90, // 55: mxaccess_gateway.v1.Write2BulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 55: mxaccess_gateway.v1.Write2BulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
90, // 56: mxaccess_gateway.v1.Write2BulkEntry.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 56: mxaccess_gateway.v1.Write2BulkEntry.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
50, // 57: mxaccess_gateway.v1.WriteSecuredBulkCommand.entries:type_name -> mxaccess_gateway.v1.WriteSecuredBulkEntry
|
50, // 57: mxaccess_gateway.v1.WriteSecuredBulkCommand.entries:type_name -> mxaccess_gateway.v1.WriteSecuredBulkEntry
|
||||||
90, // 58: mxaccess_gateway.v1.WriteSecuredBulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 58: mxaccess_gateway.v1.WriteSecuredBulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
52, // 59: mxaccess_gateway.v1.WriteSecured2BulkCommand.entries:type_name -> mxaccess_gateway.v1.WriteSecured2BulkEntry
|
52, // 59: mxaccess_gateway.v1.WriteSecured2BulkCommand.entries:type_name -> mxaccess_gateway.v1.WriteSecured2BulkEntry
|
||||||
90, // 60: mxaccess_gateway.v1.WriteSecured2BulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 60: mxaccess_gateway.v1.WriteSecured2BulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
90, // 61: mxaccess_gateway.v1.WriteSecured2BulkEntry.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 61: mxaccess_gateway.v1.WriteSecured2BulkEntry.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
101, // 62: mxaccess_gateway.v1.ShutdownWorkerCommand.grace_period:type_name -> google.protobuf.Duration
|
102, // 62: mxaccess_gateway.v1.ShutdownWorkerCommand.grace_period:type_name -> google.protobuf.Duration
|
||||||
0, // 63: mxaccess_gateway.v1.MxCommandReply.kind:type_name -> mxaccess_gateway.v1.MxCommandKind
|
0, // 63: mxaccess_gateway.v1.MxCommandReply.kind:type_name -> mxaccess_gateway.v1.MxCommandKind
|
||||||
100, // 64: mxaccess_gateway.v1.MxCommandReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus
|
101, // 64: mxaccess_gateway.v1.MxCommandReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus
|
||||||
90, // 65: mxaccess_gateway.v1.MxCommandReply.return_value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 65: mxaccess_gateway.v1.MxCommandReply.return_value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
89, // 66: mxaccess_gateway.v1.MxCommandReply.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy
|
90, // 66: mxaccess_gateway.v1.MxCommandReply.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy
|
||||||
60, // 67: mxaccess_gateway.v1.MxCommandReply.register:type_name -> mxaccess_gateway.v1.RegisterReply
|
60, // 67: mxaccess_gateway.v1.MxCommandReply.register:type_name -> mxaccess_gateway.v1.RegisterReply
|
||||||
61, // 68: mxaccess_gateway.v1.MxCommandReply.add_item:type_name -> mxaccess_gateway.v1.AddItemReply
|
61, // 68: mxaccess_gateway.v1.MxCommandReply.add_item:type_name -> mxaccess_gateway.v1.AddItemReply
|
||||||
62, // 69: mxaccess_gateway.v1.MxCommandReply.add_item2:type_name -> mxaccess_gateway.v1.AddItem2Reply
|
62, // 69: mxaccess_gateway.v1.MxCommandReply.add_item2:type_name -> mxaccess_gateway.v1.AddItem2Reply
|
||||||
@@ -8597,77 +8704,79 @@ var file_mxaccess_gateway_proto_depIdxs = []int32{
|
|||||||
74, // 88: mxaccess_gateway.v1.MxCommandReply.session_state:type_name -> mxaccess_gateway.v1.SessionStateReply
|
74, // 88: mxaccess_gateway.v1.MxCommandReply.session_state:type_name -> mxaccess_gateway.v1.SessionStateReply
|
||||||
75, // 89: mxaccess_gateway.v1.MxCommandReply.worker_info:type_name -> mxaccess_gateway.v1.WorkerInfoReply
|
75, // 89: mxaccess_gateway.v1.MxCommandReply.worker_info:type_name -> mxaccess_gateway.v1.WorkerInfoReply
|
||||||
76, // 90: mxaccess_gateway.v1.MxCommandReply.drain_events:type_name -> mxaccess_gateway.v1.DrainEventsReply
|
76, // 90: mxaccess_gateway.v1.MxCommandReply.drain_events:type_name -> mxaccess_gateway.v1.DrainEventsReply
|
||||||
89, // 91: mxaccess_gateway.v1.SuspendReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy
|
90, // 91: mxaccess_gateway.v1.SuspendReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy
|
||||||
89, // 92: mxaccess_gateway.v1.ActivateReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy
|
90, // 92: mxaccess_gateway.v1.ActivateReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy
|
||||||
68, // 93: mxaccess_gateway.v1.BulkSubscribeReply.results:type_name -> mxaccess_gateway.v1.SubscribeResult
|
68, // 93: mxaccess_gateway.v1.BulkSubscribeReply.results:type_name -> mxaccess_gateway.v1.SubscribeResult
|
||||||
89, // 94: mxaccess_gateway.v1.BulkWriteResult.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy
|
90, // 94: mxaccess_gateway.v1.BulkWriteResult.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy
|
||||||
70, // 95: mxaccess_gateway.v1.BulkWriteReply.results:type_name -> mxaccess_gateway.v1.BulkWriteResult
|
70, // 95: mxaccess_gateway.v1.BulkWriteReply.results:type_name -> mxaccess_gateway.v1.BulkWriteResult
|
||||||
90, // 96: mxaccess_gateway.v1.BulkReadResult.value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 96: mxaccess_gateway.v1.BulkReadResult.value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
102, // 97: mxaccess_gateway.v1.BulkReadResult.source_timestamp:type_name -> google.protobuf.Timestamp
|
103, // 97: mxaccess_gateway.v1.BulkReadResult.source_timestamp:type_name -> google.protobuf.Timestamp
|
||||||
89, // 98: mxaccess_gateway.v1.BulkReadResult.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy
|
90, // 98: mxaccess_gateway.v1.BulkReadResult.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy
|
||||||
72, // 99: mxaccess_gateway.v1.BulkReadReply.results:type_name -> mxaccess_gateway.v1.BulkReadResult
|
72, // 99: mxaccess_gateway.v1.BulkReadReply.results:type_name -> mxaccess_gateway.v1.BulkReadResult
|
||||||
8, // 100: mxaccess_gateway.v1.SessionStateReply.state:type_name -> mxaccess_gateway.v1.SessionState
|
8, // 100: mxaccess_gateway.v1.SessionStateReply.state:type_name -> mxaccess_gateway.v1.SessionState
|
||||||
79, // 101: mxaccess_gateway.v1.DrainEventsReply.events:type_name -> mxaccess_gateway.v1.MxEvent
|
79, // 101: mxaccess_gateway.v1.DrainEventsReply.events:type_name -> mxaccess_gateway.v1.MxEvent
|
||||||
85, // 102: mxaccess_gateway.v1.QueryActiveAlarmsReplyPayload.snapshots:type_name -> mxaccess_gateway.v1.ActiveAlarmSnapshot
|
85, // 102: mxaccess_gateway.v1.QueryActiveAlarmsReplyPayload.snapshots:type_name -> mxaccess_gateway.v1.ActiveAlarmSnapshot
|
||||||
1, // 103: mxaccess_gateway.v1.MxEvent.family:type_name -> mxaccess_gateway.v1.MxEventFamily
|
1, // 103: mxaccess_gateway.v1.MxEvent.family:type_name -> mxaccess_gateway.v1.MxEventFamily
|
||||||
90, // 104: mxaccess_gateway.v1.MxEvent.value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 104: mxaccess_gateway.v1.MxEvent.value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
102, // 105: mxaccess_gateway.v1.MxEvent.source_timestamp:type_name -> google.protobuf.Timestamp
|
103, // 105: mxaccess_gateway.v1.MxEvent.source_timestamp:type_name -> google.protobuf.Timestamp
|
||||||
89, // 106: mxaccess_gateway.v1.MxEvent.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy
|
90, // 106: mxaccess_gateway.v1.MxEvent.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy
|
||||||
102, // 107: mxaccess_gateway.v1.MxEvent.worker_timestamp:type_name -> google.protobuf.Timestamp
|
103, // 107: mxaccess_gateway.v1.MxEvent.worker_timestamp:type_name -> google.protobuf.Timestamp
|
||||||
102, // 108: mxaccess_gateway.v1.MxEvent.gateway_receive_timestamp:type_name -> google.protobuf.Timestamp
|
103, // 108: mxaccess_gateway.v1.MxEvent.gateway_receive_timestamp:type_name -> google.protobuf.Timestamp
|
||||||
80, // 109: mxaccess_gateway.v1.MxEvent.on_data_change:type_name -> mxaccess_gateway.v1.OnDataChangeEvent
|
80, // 109: mxaccess_gateway.v1.MxEvent.on_data_change:type_name -> mxaccess_gateway.v1.OnDataChangeEvent
|
||||||
81, // 110: mxaccess_gateway.v1.MxEvent.on_write_complete:type_name -> mxaccess_gateway.v1.OnWriteCompleteEvent
|
81, // 110: mxaccess_gateway.v1.MxEvent.on_write_complete:type_name -> mxaccess_gateway.v1.OnWriteCompleteEvent
|
||||||
82, // 111: mxaccess_gateway.v1.MxEvent.operation_complete:type_name -> mxaccess_gateway.v1.OperationCompleteEvent
|
82, // 111: mxaccess_gateway.v1.MxEvent.operation_complete:type_name -> mxaccess_gateway.v1.OperationCompleteEvent
|
||||||
83, // 112: mxaccess_gateway.v1.MxEvent.on_buffered_data_change:type_name -> mxaccess_gateway.v1.OnBufferedDataChangeEvent
|
83, // 112: mxaccess_gateway.v1.MxEvent.on_buffered_data_change:type_name -> mxaccess_gateway.v1.OnBufferedDataChangeEvent
|
||||||
84, // 113: mxaccess_gateway.v1.MxEvent.on_alarm_transition:type_name -> mxaccess_gateway.v1.OnAlarmTransitionEvent
|
84, // 113: mxaccess_gateway.v1.MxEvent.on_alarm_transition:type_name -> mxaccess_gateway.v1.OnAlarmTransitionEvent
|
||||||
6, // 114: mxaccess_gateway.v1.OnBufferedDataChangeEvent.data_type:type_name -> mxaccess_gateway.v1.MxDataType
|
6, // 114: mxaccess_gateway.v1.OnBufferedDataChangeEvent.data_type:type_name -> mxaccess_gateway.v1.MxDataType
|
||||||
91, // 115: mxaccess_gateway.v1.OnBufferedDataChangeEvent.quality_values:type_name -> mxaccess_gateway.v1.MxArray
|
92, // 115: mxaccess_gateway.v1.OnBufferedDataChangeEvent.quality_values:type_name -> mxaccess_gateway.v1.MxArray
|
||||||
91, // 116: mxaccess_gateway.v1.OnBufferedDataChangeEvent.timestamp_values:type_name -> mxaccess_gateway.v1.MxArray
|
92, // 116: mxaccess_gateway.v1.OnBufferedDataChangeEvent.timestamp_values:type_name -> mxaccess_gateway.v1.MxArray
|
||||||
2, // 117: mxaccess_gateway.v1.OnAlarmTransitionEvent.transition_kind:type_name -> mxaccess_gateway.v1.AlarmTransitionKind
|
2, // 117: mxaccess_gateway.v1.OnAlarmTransitionEvent.transition_kind:type_name -> mxaccess_gateway.v1.AlarmTransitionKind
|
||||||
102, // 118: mxaccess_gateway.v1.OnAlarmTransitionEvent.original_raise_timestamp:type_name -> google.protobuf.Timestamp
|
103, // 118: mxaccess_gateway.v1.OnAlarmTransitionEvent.original_raise_timestamp:type_name -> google.protobuf.Timestamp
|
||||||
102, // 119: mxaccess_gateway.v1.OnAlarmTransitionEvent.transition_timestamp:type_name -> google.protobuf.Timestamp
|
103, // 119: mxaccess_gateway.v1.OnAlarmTransitionEvent.transition_timestamp:type_name -> google.protobuf.Timestamp
|
||||||
90, // 120: mxaccess_gateway.v1.OnAlarmTransitionEvent.current_value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 120: mxaccess_gateway.v1.OnAlarmTransitionEvent.current_value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
90, // 121: mxaccess_gateway.v1.OnAlarmTransitionEvent.limit_value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 121: mxaccess_gateway.v1.OnAlarmTransitionEvent.limit_value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
102, // 122: mxaccess_gateway.v1.ActiveAlarmSnapshot.original_raise_timestamp:type_name -> google.protobuf.Timestamp
|
103, // 122: mxaccess_gateway.v1.ActiveAlarmSnapshot.original_raise_timestamp:type_name -> google.protobuf.Timestamp
|
||||||
3, // 123: mxaccess_gateway.v1.ActiveAlarmSnapshot.current_state:type_name -> mxaccess_gateway.v1.AlarmConditionState
|
3, // 123: mxaccess_gateway.v1.ActiveAlarmSnapshot.current_state:type_name -> mxaccess_gateway.v1.AlarmConditionState
|
||||||
102, // 124: mxaccess_gateway.v1.ActiveAlarmSnapshot.last_transition_timestamp:type_name -> google.protobuf.Timestamp
|
103, // 124: mxaccess_gateway.v1.ActiveAlarmSnapshot.last_transition_timestamp:type_name -> google.protobuf.Timestamp
|
||||||
90, // 125: mxaccess_gateway.v1.ActiveAlarmSnapshot.current_value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 125: mxaccess_gateway.v1.ActiveAlarmSnapshot.current_value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
90, // 126: mxaccess_gateway.v1.ActiveAlarmSnapshot.limit_value:type_name -> mxaccess_gateway.v1.MxValue
|
91, // 126: mxaccess_gateway.v1.ActiveAlarmSnapshot.limit_value:type_name -> mxaccess_gateway.v1.MxValue
|
||||||
100, // 127: mxaccess_gateway.v1.AcknowledgeAlarmReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus
|
101, // 127: mxaccess_gateway.v1.AcknowledgeAlarmReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus
|
||||||
89, // 128: mxaccess_gateway.v1.AcknowledgeAlarmReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy
|
90, // 128: mxaccess_gateway.v1.AcknowledgeAlarmReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy
|
||||||
4, // 129: mxaccess_gateway.v1.MxStatusProxy.category:type_name -> mxaccess_gateway.v1.MxStatusCategory
|
85, // 129: mxaccess_gateway.v1.AlarmFeedMessage.active_alarm:type_name -> mxaccess_gateway.v1.ActiveAlarmSnapshot
|
||||||
5, // 130: mxaccess_gateway.v1.MxStatusProxy.detected_by:type_name -> mxaccess_gateway.v1.MxStatusSource
|
84, // 130: mxaccess_gateway.v1.AlarmFeedMessage.transition:type_name -> mxaccess_gateway.v1.OnAlarmTransitionEvent
|
||||||
6, // 131: mxaccess_gateway.v1.MxValue.data_type:type_name -> mxaccess_gateway.v1.MxDataType
|
4, // 131: mxaccess_gateway.v1.MxStatusProxy.category:type_name -> mxaccess_gateway.v1.MxStatusCategory
|
||||||
102, // 132: mxaccess_gateway.v1.MxValue.timestamp_value:type_name -> google.protobuf.Timestamp
|
5, // 132: mxaccess_gateway.v1.MxStatusProxy.detected_by:type_name -> mxaccess_gateway.v1.MxStatusSource
|
||||||
91, // 133: mxaccess_gateway.v1.MxValue.array_value:type_name -> mxaccess_gateway.v1.MxArray
|
6, // 133: mxaccess_gateway.v1.MxValue.data_type:type_name -> mxaccess_gateway.v1.MxDataType
|
||||||
6, // 134: mxaccess_gateway.v1.MxArray.element_data_type:type_name -> mxaccess_gateway.v1.MxDataType
|
103, // 134: mxaccess_gateway.v1.MxValue.timestamp_value:type_name -> google.protobuf.Timestamp
|
||||||
92, // 135: mxaccess_gateway.v1.MxArray.bool_values:type_name -> mxaccess_gateway.v1.BoolArray
|
92, // 135: mxaccess_gateway.v1.MxValue.array_value:type_name -> mxaccess_gateway.v1.MxArray
|
||||||
93, // 136: mxaccess_gateway.v1.MxArray.int32_values:type_name -> mxaccess_gateway.v1.Int32Array
|
6, // 136: mxaccess_gateway.v1.MxArray.element_data_type:type_name -> mxaccess_gateway.v1.MxDataType
|
||||||
94, // 137: mxaccess_gateway.v1.MxArray.int64_values:type_name -> mxaccess_gateway.v1.Int64Array
|
93, // 137: mxaccess_gateway.v1.MxArray.bool_values:type_name -> mxaccess_gateway.v1.BoolArray
|
||||||
95, // 138: mxaccess_gateway.v1.MxArray.float_values:type_name -> mxaccess_gateway.v1.FloatArray
|
94, // 138: mxaccess_gateway.v1.MxArray.int32_values:type_name -> mxaccess_gateway.v1.Int32Array
|
||||||
96, // 139: mxaccess_gateway.v1.MxArray.double_values:type_name -> mxaccess_gateway.v1.DoubleArray
|
95, // 139: mxaccess_gateway.v1.MxArray.int64_values:type_name -> mxaccess_gateway.v1.Int64Array
|
||||||
97, // 140: mxaccess_gateway.v1.MxArray.string_values:type_name -> mxaccess_gateway.v1.StringArray
|
96, // 140: mxaccess_gateway.v1.MxArray.float_values:type_name -> mxaccess_gateway.v1.FloatArray
|
||||||
98, // 141: mxaccess_gateway.v1.MxArray.timestamp_values:type_name -> mxaccess_gateway.v1.TimestampArray
|
97, // 141: mxaccess_gateway.v1.MxArray.double_values:type_name -> mxaccess_gateway.v1.DoubleArray
|
||||||
99, // 142: mxaccess_gateway.v1.MxArray.raw_values:type_name -> mxaccess_gateway.v1.RawArray
|
98, // 142: mxaccess_gateway.v1.MxArray.string_values:type_name -> mxaccess_gateway.v1.StringArray
|
||||||
102, // 143: mxaccess_gateway.v1.TimestampArray.values:type_name -> google.protobuf.Timestamp
|
99, // 143: mxaccess_gateway.v1.MxArray.timestamp_values:type_name -> mxaccess_gateway.v1.TimestampArray
|
||||||
7, // 144: mxaccess_gateway.v1.ProtocolStatus.code:type_name -> mxaccess_gateway.v1.ProtocolStatusCode
|
100, // 144: mxaccess_gateway.v1.MxArray.raw_values:type_name -> mxaccess_gateway.v1.RawArray
|
||||||
9, // 145: mxaccess_gateway.v1.MxAccessGateway.OpenSession:input_type -> mxaccess_gateway.v1.OpenSessionRequest
|
103, // 145: mxaccess_gateway.v1.TimestampArray.values:type_name -> google.protobuf.Timestamp
|
||||||
11, // 146: mxaccess_gateway.v1.MxAccessGateway.CloseSession:input_type -> mxaccess_gateway.v1.CloseSessionRequest
|
7, // 146: mxaccess_gateway.v1.ProtocolStatus.code:type_name -> mxaccess_gateway.v1.ProtocolStatusCode
|
||||||
14, // 147: mxaccess_gateway.v1.MxAccessGateway.Invoke:input_type -> mxaccess_gateway.v1.MxCommandRequest
|
9, // 147: mxaccess_gateway.v1.MxAccessGateway.OpenSession:input_type -> mxaccess_gateway.v1.OpenSessionRequest
|
||||||
13, // 148: mxaccess_gateway.v1.MxAccessGateway.StreamEvents:input_type -> mxaccess_gateway.v1.StreamEventsRequest
|
11, // 148: mxaccess_gateway.v1.MxAccessGateway.CloseSession:input_type -> mxaccess_gateway.v1.CloseSessionRequest
|
||||||
86, // 149: mxaccess_gateway.v1.MxAccessGateway.AcknowledgeAlarm:input_type -> mxaccess_gateway.v1.AcknowledgeAlarmRequest
|
14, // 149: mxaccess_gateway.v1.MxAccessGateway.Invoke:input_type -> mxaccess_gateway.v1.MxCommandRequest
|
||||||
88, // 150: mxaccess_gateway.v1.MxAccessGateway.QueryActiveAlarms:input_type -> mxaccess_gateway.v1.QueryActiveAlarmsRequest
|
13, // 150: mxaccess_gateway.v1.MxAccessGateway.StreamEvents:input_type -> mxaccess_gateway.v1.StreamEventsRequest
|
||||||
10, // 151: mxaccess_gateway.v1.MxAccessGateway.OpenSession:output_type -> mxaccess_gateway.v1.OpenSessionReply
|
86, // 151: mxaccess_gateway.v1.MxAccessGateway.AcknowledgeAlarm:input_type -> mxaccess_gateway.v1.AcknowledgeAlarmRequest
|
||||||
12, // 152: mxaccess_gateway.v1.MxAccessGateway.CloseSession:output_type -> mxaccess_gateway.v1.CloseSessionReply
|
88, // 152: mxaccess_gateway.v1.MxAccessGateway.StreamAlarms:input_type -> mxaccess_gateway.v1.StreamAlarmsRequest
|
||||||
59, // 153: mxaccess_gateway.v1.MxAccessGateway.Invoke:output_type -> mxaccess_gateway.v1.MxCommandReply
|
10, // 153: mxaccess_gateway.v1.MxAccessGateway.OpenSession:output_type -> mxaccess_gateway.v1.OpenSessionReply
|
||||||
79, // 154: mxaccess_gateway.v1.MxAccessGateway.StreamEvents:output_type -> mxaccess_gateway.v1.MxEvent
|
12, // 154: mxaccess_gateway.v1.MxAccessGateway.CloseSession:output_type -> mxaccess_gateway.v1.CloseSessionReply
|
||||||
87, // 155: mxaccess_gateway.v1.MxAccessGateway.AcknowledgeAlarm:output_type -> mxaccess_gateway.v1.AcknowledgeAlarmReply
|
59, // 155: mxaccess_gateway.v1.MxAccessGateway.Invoke:output_type -> mxaccess_gateway.v1.MxCommandReply
|
||||||
85, // 156: mxaccess_gateway.v1.MxAccessGateway.QueryActiveAlarms:output_type -> mxaccess_gateway.v1.ActiveAlarmSnapshot
|
79, // 156: mxaccess_gateway.v1.MxAccessGateway.StreamEvents:output_type -> mxaccess_gateway.v1.MxEvent
|
||||||
151, // [151:157] is the sub-list for method output_type
|
87, // 157: mxaccess_gateway.v1.MxAccessGateway.AcknowledgeAlarm:output_type -> mxaccess_gateway.v1.AcknowledgeAlarmReply
|
||||||
145, // [145:151] is the sub-list for method input_type
|
89, // 158: mxaccess_gateway.v1.MxAccessGateway.StreamAlarms:output_type -> mxaccess_gateway.v1.AlarmFeedMessage
|
||||||
145, // [145:145] is the sub-list for extension type_name
|
153, // [153:159] is the sub-list for method output_type
|
||||||
145, // [145:145] is the sub-list for extension extendee
|
147, // [147:153] is the sub-list for method input_type
|
||||||
0, // [0:145] is the sub-list for field type_name
|
147, // [147:147] is the sub-list for extension type_name
|
||||||
|
147, // [147:147] is the sub-list for extension extendee
|
||||||
|
0, // [0:147] is the sub-list for field type_name
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_mxaccess_gateway_proto_init() }
|
func init() { file_mxaccess_gateway_proto_init() }
|
||||||
@@ -8751,7 +8860,12 @@ func file_mxaccess_gateway_proto_init() {
|
|||||||
(*MxEvent_OnAlarmTransition)(nil),
|
(*MxEvent_OnAlarmTransition)(nil),
|
||||||
}
|
}
|
||||||
file_mxaccess_gateway_proto_msgTypes[78].OneofWrappers = []any{}
|
file_mxaccess_gateway_proto_msgTypes[78].OneofWrappers = []any{}
|
||||||
file_mxaccess_gateway_proto_msgTypes[81].OneofWrappers = []any{
|
file_mxaccess_gateway_proto_msgTypes[80].OneofWrappers = []any{
|
||||||
|
(*AlarmFeedMessage_ActiveAlarm)(nil),
|
||||||
|
(*AlarmFeedMessage_SnapshotComplete)(nil),
|
||||||
|
(*AlarmFeedMessage_Transition)(nil),
|
||||||
|
}
|
||||||
|
file_mxaccess_gateway_proto_msgTypes[82].OneofWrappers = []any{
|
||||||
(*MxValue_BoolValue)(nil),
|
(*MxValue_BoolValue)(nil),
|
||||||
(*MxValue_Int32Value)(nil),
|
(*MxValue_Int32Value)(nil),
|
||||||
(*MxValue_Int64Value)(nil),
|
(*MxValue_Int64Value)(nil),
|
||||||
@@ -8762,7 +8876,7 @@ func file_mxaccess_gateway_proto_init() {
|
|||||||
(*MxValue_ArrayValue)(nil),
|
(*MxValue_ArrayValue)(nil),
|
||||||
(*MxValue_RawValue)(nil),
|
(*MxValue_RawValue)(nil),
|
||||||
}
|
}
|
||||||
file_mxaccess_gateway_proto_msgTypes[82].OneofWrappers = []any{
|
file_mxaccess_gateway_proto_msgTypes[83].OneofWrappers = []any{
|
||||||
(*MxArray_BoolValues)(nil),
|
(*MxArray_BoolValues)(nil),
|
||||||
(*MxArray_Int32Values)(nil),
|
(*MxArray_Int32Values)(nil),
|
||||||
(*MxArray_Int64Values)(nil),
|
(*MxArray_Int64Values)(nil),
|
||||||
@@ -8778,7 +8892,7 @@ func file_mxaccess_gateway_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mxaccess_gateway_proto_rawDesc), len(file_mxaccess_gateway_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mxaccess_gateway_proto_rawDesc), len(file_mxaccess_gateway_proto_rawDesc)),
|
||||||
NumEnums: 9,
|
NumEnums: 9,
|
||||||
NumMessages: 92,
|
NumMessages: 93,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 1,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const (
|
|||||||
MxAccessGateway_Invoke_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/Invoke"
|
MxAccessGateway_Invoke_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/Invoke"
|
||||||
MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents"
|
MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents"
|
||||||
MxAccessGateway_AcknowledgeAlarm_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm"
|
MxAccessGateway_AcknowledgeAlarm_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm"
|
||||||
MxAccessGateway_QueryActiveAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/QueryActiveAlarms"
|
MxAccessGateway_StreamAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamAlarms"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MxAccessGatewayClient is the client API for MxAccessGateway service.
|
// MxAccessGatewayClient is the client API for MxAccessGateway service.
|
||||||
@@ -38,7 +38,12 @@ type MxAccessGatewayClient interface {
|
|||||||
Invoke(ctx context.Context, in *MxCommandRequest, opts ...grpc.CallOption) (*MxCommandReply, error)
|
Invoke(ctx context.Context, in *MxCommandRequest, opts ...grpc.CallOption) (*MxCommandReply, error)
|
||||||
StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MxEvent], error)
|
StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MxEvent], error)
|
||||||
AcknowledgeAlarm(ctx context.Context, in *AcknowledgeAlarmRequest, opts ...grpc.CallOption) (*AcknowledgeAlarmReply, error)
|
AcknowledgeAlarm(ctx context.Context, in *AcknowledgeAlarmRequest, opts ...grpc.CallOption) (*AcknowledgeAlarmReply, error)
|
||||||
QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error)
|
// Session-less central alarm feed. The stream opens with the current
|
||||||
|
// active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
// `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
// Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
// fan out from the single monitor without opening a worker session.
|
||||||
|
StreamAlarms(ctx context.Context, in *StreamAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AlarmFeedMessage], error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type mxAccessGatewayClient struct {
|
type mxAccessGatewayClient struct {
|
||||||
@@ -108,13 +113,13 @@ func (c *mxAccessGatewayClient) AcknowledgeAlarm(ctx context.Context, in *Acknow
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *mxAccessGatewayClient) QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error) {
|
func (c *mxAccessGatewayClient) StreamAlarms(ctx context.Context, in *StreamAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AlarmFeedMessage], error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[1], MxAccessGateway_QueryActiveAlarms_FullMethodName, cOpts...)
|
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[1], MxAccessGateway_StreamAlarms_FullMethodName, cOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
x := &grpc.GenericClientStream[QueryActiveAlarmsRequest, ActiveAlarmSnapshot]{ClientStream: stream}
|
x := &grpc.GenericClientStream[StreamAlarmsRequest, AlarmFeedMessage]{ClientStream: stream}
|
||||||
if err := x.ClientStream.SendMsg(in); err != nil {
|
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -125,7 +130,7 @@ func (c *mxAccessGatewayClient) QueryActiveAlarms(ctx context.Context, in *Query
|
|||||||
}
|
}
|
||||||
|
|
||||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
type MxAccessGateway_QueryActiveAlarmsClient = grpc.ServerStreamingClient[ActiveAlarmSnapshot]
|
type MxAccessGateway_StreamAlarmsClient = grpc.ServerStreamingClient[AlarmFeedMessage]
|
||||||
|
|
||||||
// MxAccessGatewayServer is the server API for MxAccessGateway service.
|
// MxAccessGatewayServer is the server API for MxAccessGateway service.
|
||||||
// All implementations must embed UnimplementedMxAccessGatewayServer
|
// All implementations must embed UnimplementedMxAccessGatewayServer
|
||||||
@@ -138,7 +143,12 @@ type MxAccessGatewayServer interface {
|
|||||||
Invoke(context.Context, *MxCommandRequest) (*MxCommandReply, error)
|
Invoke(context.Context, *MxCommandRequest) (*MxCommandReply, error)
|
||||||
StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[MxEvent]) error
|
StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[MxEvent]) error
|
||||||
AcknowledgeAlarm(context.Context, *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error)
|
AcknowledgeAlarm(context.Context, *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error)
|
||||||
QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error
|
// Session-less central alarm feed. The stream opens with the current
|
||||||
|
// active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
// `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
// Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
// fan out from the single monitor without opening a worker session.
|
||||||
|
StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error
|
||||||
mustEmbedUnimplementedMxAccessGatewayServer()
|
mustEmbedUnimplementedMxAccessGatewayServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,8 +174,8 @@ func (UnimplementedMxAccessGatewayServer) StreamEvents(*StreamEventsRequest, grp
|
|||||||
func (UnimplementedMxAccessGatewayServer) AcknowledgeAlarm(context.Context, *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) {
|
func (UnimplementedMxAccessGatewayServer) AcknowledgeAlarm(context.Context, *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method AcknowledgeAlarm not implemented")
|
return nil, status.Error(codes.Unimplemented, "method AcknowledgeAlarm not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedMxAccessGatewayServer) QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error {
|
func (UnimplementedMxAccessGatewayServer) StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error {
|
||||||
return status.Error(codes.Unimplemented, "method QueryActiveAlarms not implemented")
|
return status.Error(codes.Unimplemented, "method StreamAlarms not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedMxAccessGatewayServer) mustEmbedUnimplementedMxAccessGatewayServer() {}
|
func (UnimplementedMxAccessGatewayServer) mustEmbedUnimplementedMxAccessGatewayServer() {}
|
||||||
func (UnimplementedMxAccessGatewayServer) testEmbeddedByValue() {}
|
func (UnimplementedMxAccessGatewayServer) testEmbeddedByValue() {}
|
||||||
@@ -271,16 +281,16 @@ func _MxAccessGateway_AcknowledgeAlarm_Handler(srv interface{}, ctx context.Cont
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func _MxAccessGateway_QueryActiveAlarms_Handler(srv interface{}, stream grpc.ServerStream) error {
|
func _MxAccessGateway_StreamAlarms_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
m := new(QueryActiveAlarmsRequest)
|
m := new(StreamAlarmsRequest)
|
||||||
if err := stream.RecvMsg(m); err != nil {
|
if err := stream.RecvMsg(m); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return srv.(MxAccessGatewayServer).QueryActiveAlarms(m, &grpc.GenericServerStream[QueryActiveAlarmsRequest, ActiveAlarmSnapshot]{ServerStream: stream})
|
return srv.(MxAccessGatewayServer).StreamAlarms(m, &grpc.GenericServerStream[StreamAlarmsRequest, AlarmFeedMessage]{ServerStream: stream})
|
||||||
}
|
}
|
||||||
|
|
||||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
type MxAccessGateway_QueryActiveAlarmsServer = grpc.ServerStreamingServer[ActiveAlarmSnapshot]
|
type MxAccessGateway_StreamAlarmsServer = grpc.ServerStreamingServer[AlarmFeedMessage]
|
||||||
|
|
||||||
// MxAccessGateway_ServiceDesc is the grpc.ServiceDesc for MxAccessGateway service.
|
// MxAccessGateway_ServiceDesc is the grpc.ServiceDesc for MxAccessGateway service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
@@ -313,8 +323,8 @@ var MxAccessGateway_ServiceDesc = grpc.ServiceDesc{
|
|||||||
ServerStreams: true,
|
ServerStreams: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
StreamName: "QueryActiveAlarms",
|
StreamName: "StreamAlarms",
|
||||||
Handler: _MxAccessGateway_QueryActiveAlarms_Handler,
|
Handler: _MxAccessGateway_StreamAlarms_Handler,
|
||||||
ServerStreams: true,
|
ServerStreams: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,22 +31,24 @@ func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequ
|
|||||||
return reply, nil
|
return reply, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryActiveAlarms streams a snapshot of all alarms currently Active or
|
// StreamAlarms attaches to the gateway's central alarm feed. The stream opens
|
||||||
// ActiveAcked — the gateway's ConditionRefresh equivalent. Used after reconnect
|
// with one AlarmFeedMessage per currently-active alarm (the ConditionRefresh
|
||||||
// to seed local Part 9 state, or to reconcile alarms that may have been missed
|
// snapshot), then a single snapshot-complete sentinel, then a transition for
|
||||||
// during a transport blip.
|
// every subsequent raise / acknowledge / clear. It is served by the gateway's
|
||||||
|
// always-on alarm monitor — no worker session is opened — so any number of
|
||||||
|
// clients may attach.
|
||||||
//
|
//
|
||||||
// The returned stream is owned by the caller; cancel ctx to release it.
|
// The returned stream is owned by the caller; cancel ctx to release it.
|
||||||
// Optional alarm-reference prefix scoping (req.AlarmFilterPrefix) limits the
|
// Optional alarm-reference prefix scoping (req.AlarmFilterPrefix) limits the
|
||||||
// stream to a sub-tree.
|
// stream to a sub-tree.
|
||||||
func (c *Client) QueryActiveAlarms(ctx context.Context, req *QueryActiveAlarmsRequest) (QueryActiveAlarmsClient, error) {
|
func (c *Client) StreamAlarms(ctx context.Context, req *StreamAlarmsRequest) (StreamAlarmsClient, error) {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return nil, errors.New("mxgateway: query active alarms request is required")
|
return nil, errors.New("mxgateway: stream alarms request is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
stream, err := c.raw.QueryActiveAlarms(ctx, req)
|
stream, err := c.raw.StreamAlarms(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &GatewayError{Op: "query active alarms", Err: err}
|
return nil, &GatewayError{Op: "stream alarms", Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return stream, nil
|
return stream, nil
|
||||||
|
|||||||
@@ -14,13 +14,11 @@ import (
|
|||||||
"google.golang.org/grpc/test/bufconn"
|
"google.golang.org/grpc/test/bufconn"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PR E.4 — pins the Go SDK surface for the new alarm RPCs:
|
// Pins the Go SDK surface for the alarm RPCs: AcknowledgeAlarm + StreamAlarms.
|
||||||
// AcknowledgeAlarm + QueryActiveAlarms.
|
|
||||||
|
|
||||||
func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
||||||
fake := &fakeGatewayWithAlarms{
|
fake := &fakeGatewayWithAlarms{
|
||||||
acknowledgeReply: &pb.AcknowledgeAlarmReply{
|
acknowledgeReply: &pb.AcknowledgeAlarmReply{
|
||||||
SessionId: "session-1",
|
|
||||||
CorrelationId: "corr-1",
|
CorrelationId: "corr-1",
|
||||||
ProtocolStatus: &pb.ProtocolStatus{
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
@@ -35,7 +33,6 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
reply, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
reply, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
||||||
SessionId: "session-1",
|
|
||||||
ClientCorrelationId: "corr-1",
|
ClientCorrelationId: "corr-1",
|
||||||
AlarmFullReference: "Tank01.Level.HiHi",
|
AlarmFullReference: "Tank01.Level.HiHi",
|
||||||
Comment: "investigating",
|
Comment: "investigating",
|
||||||
@@ -77,7 +74,6 @@ func TestAcknowledgeAlarmMapsUnauthenticated(t *testing.T) {
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
_, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
_, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
||||||
SessionId: "session-1",
|
|
||||||
AlarmFullReference: "Tank01.Level.HiHi",
|
AlarmFullReference: "Tank01.Level.HiHi",
|
||||||
OperatorUser: "alice",
|
OperatorUser: "alice",
|
||||||
})
|
})
|
||||||
@@ -93,7 +89,7 @@ func TestAcknowledgeAlarmMapsUnauthenticated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryActiveAlarmsStreamsSnapshots(t *testing.T) {
|
func TestStreamAlarmsStreamsSnapshotThenSnapshotComplete(t *testing.T) {
|
||||||
fake := &fakeGatewayWithAlarms{
|
fake := &fakeGatewayWithAlarms{
|
||||||
activeSnapshots: []*pb.ActiveAlarmSnapshot{
|
activeSnapshots: []*pb.ActiveAlarmSnapshot{
|
||||||
{
|
{
|
||||||
@@ -111,46 +107,46 @@ func TestQueryActiveAlarmsStreamsSnapshots(t *testing.T) {
|
|||||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
stream, err := client.QueryActiveAlarms(context.Background(), &pb.QueryActiveAlarmsRequest{
|
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{})
|
||||||
SessionId: "session-1",
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("QueryActiveAlarms() error = %v", err)
|
t.Fatalf("StreamAlarms() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var received []*pb.ActiveAlarmSnapshot
|
var received []*pb.AlarmFeedMessage
|
||||||
for {
|
for {
|
||||||
snap, err := stream.Recv()
|
msg, err := stream.Recv()
|
||||||
if errors.Is(err, io.EOF) {
|
if errors.Is(err, io.EOF) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("stream.Recv() error = %v", err)
|
t.Fatalf("stream.Recv() error = %v", err)
|
||||||
}
|
}
|
||||||
received = append(received, snap)
|
received = append(received, msg)
|
||||||
}
|
}
|
||||||
if len(received) != 2 {
|
if len(received) != 3 {
|
||||||
t.Fatalf("snapshot count = %d, want 2", len(received))
|
t.Fatalf("message count = %d, want 3", len(received))
|
||||||
}
|
}
|
||||||
if received[0].GetAlarmFullReference() != "Tank01.Level.HiHi" {
|
if received[0].GetActiveAlarm().GetAlarmFullReference() != "Tank01.Level.HiHi" {
|
||||||
t.Fatalf("snapshot[0] ref = %q", received[0].GetAlarmFullReference())
|
t.Fatalf("message[0] ref = %q", received[0].GetActiveAlarm().GetAlarmFullReference())
|
||||||
}
|
}
|
||||||
if received[1].GetCurrentState() != pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE_ACKED {
|
if received[1].GetActiveAlarm().GetCurrentState() != pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE_ACKED {
|
||||||
t.Fatalf("snapshot[1] state = %v", received[1].GetCurrentState())
|
t.Fatalf("message[1] state = %v", received[1].GetActiveAlarm().GetCurrentState())
|
||||||
|
}
|
||||||
|
if !received[2].GetSnapshotComplete() {
|
||||||
|
t.Fatalf("final message is not snapshot_complete")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryActiveAlarmsPassesFilterPrefix(t *testing.T) {
|
func TestStreamAlarmsPassesFilterPrefix(t *testing.T) {
|
||||||
fake := &fakeGatewayWithAlarms{}
|
fake := &fakeGatewayWithAlarms{}
|
||||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
stream, err := client.QueryActiveAlarms(context.Background(), &pb.QueryActiveAlarmsRequest{
|
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{
|
||||||
SessionId: "session-1",
|
|
||||||
AlarmFilterPrefix: "Tank01.",
|
AlarmFilterPrefix: "Tank01.",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("QueryActiveAlarms() error = %v", err)
|
t.Fatalf("StreamAlarms() error = %v", err)
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
_, err := stream.Recv()
|
_, err := stream.Recv()
|
||||||
@@ -162,7 +158,7 @@ func TestQueryActiveAlarmsPassesFilterPrefix(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if got := fake.queryRequest.GetAlarmFilterPrefix(); got != "Tank01." {
|
if got := fake.streamRequest.GetAlarmFilterPrefix(); got != "Tank01." {
|
||||||
t.Fatalf("captured filter prefix = %q", got)
|
t.Fatalf("captured filter prefix = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,7 +171,7 @@ type fakeGatewayWithAlarms struct {
|
|||||||
acknowledgeError error
|
acknowledgeError error
|
||||||
acknowledgeAuth string
|
acknowledgeAuth string
|
||||||
|
|
||||||
queryRequest *pb.QueryActiveAlarmsRequest
|
streamRequest *pb.StreamAlarmsRequest
|
||||||
activeSnapshots []*pb.ActiveAlarmSnapshot
|
activeSnapshots []*pb.ActiveAlarmSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,21 +185,24 @@ func (s *fakeGatewayWithAlarms) AcknowledgeAlarm(ctx context.Context, req *pb.Ac
|
|||||||
return s.acknowledgeReply, nil
|
return s.acknowledgeReply, nil
|
||||||
}
|
}
|
||||||
return &pb.AcknowledgeAlarmReply{
|
return &pb.AcknowledgeAlarmReply{
|
||||||
SessionId: req.GetSessionId(),
|
|
||||||
ProtocolStatus: &pb.ProtocolStatus{
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeGatewayWithAlarms) QueryActiveAlarms(req *pb.QueryActiveAlarmsRequest, stream grpc.ServerStreamingServer[pb.ActiveAlarmSnapshot]) error {
|
func (s *fakeGatewayWithAlarms) StreamAlarms(req *pb.StreamAlarmsRequest, stream grpc.ServerStreamingServer[pb.AlarmFeedMessage]) error {
|
||||||
s.queryRequest = req
|
s.streamRequest = req
|
||||||
for _, snap := range s.activeSnapshots {
|
for _, snap := range s.activeSnapshots {
|
||||||
if err := stream.Send(snap); err != nil {
|
if err := stream.Send(&pb.AlarmFeedMessage{
|
||||||
|
Payload: &pb.AlarmFeedMessage_ActiveAlarm{ActiveAlarm: snap},
|
||||||
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return stream.Send(&pb.AlarmFeedMessage{
|
||||||
|
Payload: &pb.AlarmFeedMessage_SnapshotComplete{SnapshotComplete: true},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBufconnClientWithAlarms(t *testing.T, fake *fakeGatewayWithAlarms) (*Client, func()) {
|
func newBufconnClientWithAlarms(t *testing.T, fake *fakeGatewayWithAlarms) (*Client, func()) {
|
||||||
|
|||||||
@@ -110,9 +110,12 @@ type (
|
|||||||
AcknowledgeAlarmRequest = pb.AcknowledgeAlarmRequest
|
AcknowledgeAlarmRequest = pb.AcknowledgeAlarmRequest
|
||||||
// AcknowledgeAlarmReply is the gateway AcknowledgeAlarm reply message.
|
// AcknowledgeAlarmReply is the gateway AcknowledgeAlarm reply message.
|
||||||
AcknowledgeAlarmReply = pb.AcknowledgeAlarmReply
|
AcknowledgeAlarmReply = pb.AcknowledgeAlarmReply
|
||||||
// QueryActiveAlarmsRequest is the gateway QueryActiveAlarms request message.
|
// StreamAlarmsRequest is the gateway StreamAlarms request message.
|
||||||
QueryActiveAlarmsRequest = pb.QueryActiveAlarmsRequest
|
StreamAlarmsRequest = pb.StreamAlarmsRequest
|
||||||
// ActiveAlarmSnapshot is one row in a ConditionRefresh stream.
|
// AlarmFeedMessage is one message on the StreamAlarms feed — an
|
||||||
|
// active-alarm snapshot row, a snapshot-complete sentinel, or a transition.
|
||||||
|
AlarmFeedMessage = pb.AlarmFeedMessage
|
||||||
|
// ActiveAlarmSnapshot is one currently-active alarm in the feed snapshot.
|
||||||
ActiveAlarmSnapshot = pb.ActiveAlarmSnapshot
|
ActiveAlarmSnapshot = pb.ActiveAlarmSnapshot
|
||||||
// OnAlarmTransitionEvent is the body carried by alarm-transition MxEvents.
|
// OnAlarmTransitionEvent is the body carried by alarm-transition MxEvents.
|
||||||
OnAlarmTransitionEvent = pb.OnAlarmTransitionEvent
|
OnAlarmTransitionEvent = pb.OnAlarmTransitionEvent
|
||||||
@@ -126,9 +129,9 @@ type AlarmTransitionKind = pb.AlarmTransitionKind
|
|||||||
// ConditionRefresh snapshot.
|
// ConditionRefresh snapshot.
|
||||||
type AlarmConditionState = pb.AlarmConditionState
|
type AlarmConditionState = pb.AlarmConditionState
|
||||||
|
|
||||||
// QueryActiveAlarmsClient is the generated server-streaming client for the
|
// StreamAlarmsClient is the generated server-streaming client for the
|
||||||
// QueryActiveAlarms RPC.
|
// StreamAlarms RPC.
|
||||||
type QueryActiveAlarmsClient = pb.MxAccessGateway_QueryActiveAlarmsClient
|
type StreamAlarmsClient = pb.MxAccessGateway_StreamAlarmsClient
|
||||||
|
|
||||||
// Enumerations from the generated contract re-exported for client callers.
|
// Enumerations from the generated contract re-exported for client callers.
|
||||||
type (
|
type (
|
||||||
|
|||||||
+166
@@ -3,6 +3,7 @@ package com.dohertylan.mxgateway.cli;
|
|||||||
import com.dohertylan.mxgateway.client.DeployEventStream;
|
import com.dohertylan.mxgateway.client.DeployEventStream;
|
||||||
import com.dohertylan.mxgateway.client.GalaxyRepositoryClient;
|
import com.dohertylan.mxgateway.client.GalaxyRepositoryClient;
|
||||||
import com.dohertylan.mxgateway.client.MxEventStream;
|
import com.dohertylan.mxgateway.client.MxEventStream;
|
||||||
|
import com.dohertylan.mxgateway.client.MxGatewayAlarmFeedSubscription;
|
||||||
import com.dohertylan.mxgateway.client.MxGatewayClient;
|
import com.dohertylan.mxgateway.client.MxGatewayClient;
|
||||||
import com.dohertylan.mxgateway.client.MxGatewayClientOptions;
|
import com.dohertylan.mxgateway.client.MxGatewayClientOptions;
|
||||||
import com.dohertylan.mxgateway.client.MxGatewayClientVersion;
|
import com.dohertylan.mxgateway.client.MxGatewayClientVersion;
|
||||||
@@ -28,14 +29,23 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ArrayBlockingQueue;
|
||||||
|
import java.util.concurrent.BlockingQueue;
|
||||||
import java.util.concurrent.Callable;
|
import java.util.concurrent.Callable;
|
||||||
|
import io.grpc.stub.StreamObserver;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
|
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
|
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
|
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
|
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
|
||||||
@@ -127,6 +137,8 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
commandLine.addSubcommand("bench-read-bulk", new BenchReadBulkCommand(clientFactory));
|
commandLine.addSubcommand("bench-read-bulk", new BenchReadBulkCommand(clientFactory));
|
||||||
commandLine.addSubcommand("write", new WriteCommand(clientFactory));
|
commandLine.addSubcommand("write", new WriteCommand(clientFactory));
|
||||||
commandLine.addSubcommand("stream-events", new StreamEventsCommand(clientFactory));
|
commandLine.addSubcommand("stream-events", new StreamEventsCommand(clientFactory));
|
||||||
|
commandLine.addSubcommand("stream-alarms", new StreamAlarmsCommand(clientFactory));
|
||||||
|
commandLine.addSubcommand("acknowledge-alarm", new AcknowledgeAlarmCommand(clientFactory));
|
||||||
commandLine.addSubcommand("smoke", new SmokeCommand(clientFactory));
|
commandLine.addSubcommand("smoke", new SmokeCommand(clientFactory));
|
||||||
commandLine.addSubcommand("galaxy-test", new GalaxyTestConnectionCommand());
|
commandLine.addSubcommand("galaxy-test", new GalaxyTestConnectionCommand());
|
||||||
commandLine.addSubcommand("galaxy-deploy-time", new GalaxyDeployTimeCommand());
|
commandLine.addSubcommand("galaxy-deploy-time", new GalaxyDeployTimeCommand());
|
||||||
@@ -139,6 +151,9 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
/** Sentinel written to stdout after every command result in batch mode. */
|
/** Sentinel written to stdout after every command result in batch mode. */
|
||||||
static final String BATCH_EOR = "__MXGW_BATCH_EOR__";
|
static final String BATCH_EOR = "__MXGW_BATCH_EOR__";
|
||||||
|
|
||||||
|
/** Sentinel queued by {@code stream-alarms} to mark a clean end of the alarm feed. */
|
||||||
|
private static final Object ALARM_FEED_END = new Object();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads one CLI invocation per stdin line, executes each via a fresh
|
* Reads one CLI invocation per stdin line, executes each via a fresh
|
||||||
* {@link CommandLine}, and writes {@value #BATCH_EOR} to stdout after
|
* {@link CommandLine}, and writes {@value #BATCH_EOR} to stdout after
|
||||||
@@ -1155,6 +1170,115 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Command(name = "stream-alarms", description = "Streams the gateway central alarm feed.")
|
||||||
|
static final class StreamAlarmsCommand extends GatewayCommand {
|
||||||
|
@Option(names = "--filter-prefix", description = "Alarm-reference prefix scoping the feed; empty means unscoped.")
|
||||||
|
String filterPrefix = "";
|
||||||
|
|
||||||
|
@Option(names = "--limit", defaultValue = "0", description = "Maximum feed messages to print.")
|
||||||
|
int limit;
|
||||||
|
|
||||||
|
StreamAlarmsCommand(MxGatewayCliClientFactory clientFactory) {
|
||||||
|
super(clientFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer call() {
|
||||||
|
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||||
|
// The async alarm feed delivers on a background gRPC thread; buffer
|
||||||
|
// messages in a bounded queue and drain them on this thread so the
|
||||||
|
// --limit termination mirrors stream-events. 1024 absorbs the
|
||||||
|
// gateway's initial active-alarm snapshot burst.
|
||||||
|
BlockingQueue<Object> queue = new ArrayBlockingQueue<>(1024);
|
||||||
|
StreamAlarmsRequest request = StreamAlarmsRequest.newBuilder()
|
||||||
|
.setAlarmFilterPrefix(filterPrefix)
|
||||||
|
.build();
|
||||||
|
MxGatewayAlarmFeedSubscription subscription =
|
||||||
|
client.streamAlarms(request, new StreamObserver<>() {
|
||||||
|
@Override
|
||||||
|
public void onNext(AlarmFeedMessage value) {
|
||||||
|
queue.offer(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable error) {
|
||||||
|
queue.offer(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCompleted() {
|
||||||
|
queue.offer(ALARM_FEED_END);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
int count = 0;
|
||||||
|
while (true) {
|
||||||
|
Object item = queue.take();
|
||||||
|
if (item == ALARM_FEED_END) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (item instanceof Throwable error) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"gateway stream alarms failed: " + error.getMessage(), error);
|
||||||
|
}
|
||||||
|
AlarmFeedMessage message = (AlarmFeedMessage) item;
|
||||||
|
if (json) {
|
||||||
|
client.out().println(protoJson(message));
|
||||||
|
} else {
|
||||||
|
client.out().println(formatAlarmFeedMessage(message));
|
||||||
|
}
|
||||||
|
client.out().flush();
|
||||||
|
count++;
|
||||||
|
if (limit > 0 && count >= limit) {
|
||||||
|
subscription.cancel();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (InterruptedException error) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
subscription.cancel();
|
||||||
|
} finally {
|
||||||
|
subscription.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command(name = "acknowledge-alarm", description = "Acknowledges an active MXAccess alarm.")
|
||||||
|
static final class AcknowledgeAlarmCommand extends GatewayCommand {
|
||||||
|
@Option(names = "--reference", required = true, description = "Full alarm reference to acknowledge.")
|
||||||
|
String reference;
|
||||||
|
|
||||||
|
@Option(names = "--comment", description = "Operator acknowledge comment.")
|
||||||
|
String comment = "";
|
||||||
|
|
||||||
|
@Option(names = "--operator", description = "Operator user performing the acknowledge.")
|
||||||
|
String operator = "";
|
||||||
|
|
||||||
|
AcknowledgeAlarmCommand(MxGatewayCliClientFactory clientFactory) {
|
||||||
|
super(clientFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer call() {
|
||||||
|
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||||
|
AcknowledgeAlarmReply reply = client.acknowledgeAlarm(AcknowledgeAlarmRequest.newBuilder()
|
||||||
|
.setAlarmFullReference(reference)
|
||||||
|
.setComment(comment)
|
||||||
|
.setOperatorUser(operator)
|
||||||
|
.build());
|
||||||
|
writeOutput(
|
||||||
|
"acknowledge-alarm",
|
||||||
|
common,
|
||||||
|
json,
|
||||||
|
reply,
|
||||||
|
() -> Integer.toString(reply.getHresult()));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Command(name = "smoke", description = "Runs a bounded open/register/add/advise flow.")
|
@Command(name = "smoke", description = "Runs a bounded open/register/add/advise flow.")
|
||||||
static final class SmokeCommand extends GatewayCommand {
|
static final class SmokeCommand extends GatewayCommand {
|
||||||
@Option(names = "--client-name", defaultValue = "mxgw-java-smoke", description = "MXAccess client name.")
|
@Option(names = "--client-name", defaultValue = "mxgw-java-smoke", description = "MXAccess client name.")
|
||||||
@@ -1329,6 +1453,11 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
|
|
||||||
MxGatewayCliSession session(String sessionId);
|
MxGatewayCliSession session(String sessionId);
|
||||||
|
|
||||||
|
AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request);
|
||||||
|
|
||||||
|
MxGatewayAlarmFeedSubscription streamAlarms(
|
||||||
|
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
void close();
|
void close();
|
||||||
}
|
}
|
||||||
@@ -1401,6 +1530,17 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
return new GrpcMxGatewayCliSession(MxGatewaySession.forSessionId(client, sessionId));
|
return new GrpcMxGatewayCliSession(MxGatewaySession.forSessionId(client, sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request) {
|
||||||
|
return client.acknowledgeAlarm(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxGatewayAlarmFeedSubscription streamAlarms(
|
||||||
|
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
|
||||||
|
return client.streamAlarms(request, observer);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
client.close();
|
client.close();
|
||||||
@@ -1576,6 +1716,32 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders one {@link AlarmFeedMessage} in the CLI's plain-text output
|
||||||
|
* style, distinguishing the active-alarm snapshot, snapshot-complete
|
||||||
|
* sentinel, and transition cases of the message's {@code payload} oneof.
|
||||||
|
*/
|
||||||
|
private static String formatAlarmFeedMessage(AlarmFeedMessage message) {
|
||||||
|
return switch (message.getPayloadCase()) {
|
||||||
|
case ACTIVE_ALARM -> {
|
||||||
|
ActiveAlarmSnapshot alarm = message.getActiveAlarm();
|
||||||
|
yield String.format(
|
||||||
|
"active-alarm %s state=%s severity=%d",
|
||||||
|
alarm.getAlarmFullReference(), alarm.getCurrentState().name(), alarm.getSeverity());
|
||||||
|
}
|
||||||
|
case SNAPSHOT_COMPLETE -> "snapshot-complete";
|
||||||
|
case TRANSITION -> {
|
||||||
|
OnAlarmTransitionEvent transition = message.getTransition();
|
||||||
|
yield String.format(
|
||||||
|
"transition %s kind=%s severity=%d",
|
||||||
|
transition.getAlarmFullReference(),
|
||||||
|
transition.getTransitionKind().name(),
|
||||||
|
transition.getSeverity());
|
||||||
|
}
|
||||||
|
case PAYLOAD_NOT_SET -> "unknown";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static MxValue parseValue(String type, String text) {
|
private static MxValue parseValue(String type, String text) {
|
||||||
return switch (type) {
|
return switch (type) {
|
||||||
case "bool" -> MxValues.boolValue(Boolean.parseBoolean(text));
|
case "bool" -> MxValues.boolValue(Boolean.parseBoolean(text));
|
||||||
|
|||||||
+110
@@ -8,10 +8,18 @@ import java.io.ByteArrayInputStream;
|
|||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.PrintWriter;
|
import java.io.PrintWriter;
|
||||||
import java.io.StringWriter;
|
import java.io.StringWriter;
|
||||||
|
import com.dohertylan.mxgateway.client.MxGatewayAlarmFeedSubscription;
|
||||||
|
import io.grpc.stub.StreamObserver;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
|
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmTransitionKind;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
|
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
|
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||||
@@ -20,9 +28,11 @@ import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
|
|||||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
||||||
@@ -389,6 +399,70 @@ final class MxGatewayCliTests {
|
|||||||
assertTrue(output.contains("TestMachine_002.TestChangingInt"), output);
|
assertTrue(output.contains("TestMachine_002.TestChangingInt"), output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- stream-alarms / acknowledge-alarm subcommands ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamAlarmsCommandForwardsFilterPrefixAndPrintsFeedMessages() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(factory, "stream-alarms", "--filter-prefix", "Tank01");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("Tank01", factory.client.lastStreamAlarmsRequest.getAlarmFilterPrefix());
|
||||||
|
String out = run.output();
|
||||||
|
assertTrue(out.contains("active-alarm Tank01.Level.HiHi"), out);
|
||||||
|
assertTrue(out.contains("snapshot-complete"), out);
|
||||||
|
assertTrue(out.contains("transition Tank01.Level.HiHi"), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamAlarmsCommandHonoursLimit() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(factory, "stream-alarms", "--limit", "1");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
long lines = run.output().lines().filter(line -> !line.isBlank()).count();
|
||||||
|
assertEquals(1, lines, "expected exactly one feed message with --limit 1, got: " + run.output());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamAlarmsCommandPrintsJson() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(factory, "stream-alarms", "--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertTrue(run.output().contains("\"activeAlarm\""), run.output());
|
||||||
|
assertTrue(run.output().contains("\"snapshotComplete\""), run.output());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acknowledgeAlarmCommandForwardsOptionsAndPrintsReply() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(
|
||||||
|
factory,
|
||||||
|
"acknowledge-alarm",
|
||||||
|
"--reference",
|
||||||
|
"Tank01.Level.HiHi",
|
||||||
|
"--comment",
|
||||||
|
"checked",
|
||||||
|
"--operator",
|
||||||
|
"operator1",
|
||||||
|
"--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference());
|
||||||
|
assertEquals("checked", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||||
|
assertEquals("operator1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser());
|
||||||
|
assertTrue(run.output().contains("\"command\":\"acknowledge-alarm\""), run.output());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acknowledgeAlarmCommandRequiresReference() {
|
||||||
|
CliRun run = execute(new FakeClientFactory(), "acknowledge-alarm", "--comment", "checked");
|
||||||
|
|
||||||
|
assertFalse(run.exitCode() == 0, "expected non-zero exit without --reference");
|
||||||
|
assertTrue(run.errors().contains("--reference"), run.errors());
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Client.Java-027: batch subcommand ----
|
// ---- Client.Java-027: batch subcommand ----
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -501,6 +575,8 @@ final class MxGatewayCliTests {
|
|||||||
private final PrintWriter out;
|
private final PrintWriter out;
|
||||||
private final FakeSession session = new FakeSession();
|
private final FakeSession session = new FakeSession();
|
||||||
private boolean closeCalled;
|
private boolean closeCalled;
|
||||||
|
private AcknowledgeAlarmRequest lastAcknowledgeAlarmRequest;
|
||||||
|
private StreamAlarmsRequest lastStreamAlarmsRequest;
|
||||||
|
|
||||||
private FakeClient(PrintWriter out) {
|
private FakeClient(PrintWriter out) {
|
||||||
this.out = out;
|
this.out = out;
|
||||||
@@ -534,6 +610,40 @@ final class MxGatewayCliTests {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request) {
|
||||||
|
lastAcknowledgeAlarmRequest = request;
|
||||||
|
return AcknowledgeAlarmReply.newBuilder()
|
||||||
|
.setCorrelationId(request.getClientCorrelationId())
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.setHresult(0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxGatewayAlarmFeedSubscription streamAlarms(
|
||||||
|
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
|
||||||
|
lastStreamAlarmsRequest = request;
|
||||||
|
// Replay a deterministic active-alarm snapshot, snapshot-complete
|
||||||
|
// sentinel, transition, then complete the feed so the CLI command
|
||||||
|
// drains a bounded stream without contacting a live gateway.
|
||||||
|
observer.onNext(AlarmFeedMessage.newBuilder()
|
||||||
|
.setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
|
||||||
|
.setAlarmFullReference("Tank01.Level.HiHi")
|
||||||
|
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
|
||||||
|
.setSeverity(700))
|
||||||
|
.build());
|
||||||
|
observer.onNext(AlarmFeedMessage.newBuilder().setSnapshotComplete(true).build());
|
||||||
|
observer.onNext(AlarmFeedMessage.newBuilder()
|
||||||
|
.setTransition(OnAlarmTransitionEvent.newBuilder()
|
||||||
|
.setAlarmFullReference("Tank01.Level.HiHi")
|
||||||
|
.setTransitionKind(AlarmTransitionKind.ALARM_TRANSITION_KIND_ACKNOWLEDGE)
|
||||||
|
.setSeverity(700))
|
||||||
|
.build());
|
||||||
|
observer.onCompleted();
|
||||||
|
return new MxGatewayAlarmFeedSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-11
@@ -5,33 +5,33 @@ import io.grpc.stub.ClientResponseObserver;
|
|||||||
import io.grpc.stub.StreamObserver;
|
import io.grpc.stub.StreamObserver;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancellable handle returned by {@code queryActiveAlarms}.
|
* Cancellable handle returned by {@code streamAlarms}.
|
||||||
*
|
*
|
||||||
* <p>Wraps a caller-supplied {@link StreamObserver} and exposes a
|
* <p>Wraps a caller-supplied {@link StreamObserver} and exposes a
|
||||||
* {@link #cancel()} entry point that aborts the underlying gRPC call. The
|
* {@link #cancel()} entry point that aborts the underlying gRPC call. The
|
||||||
* subscription also implements {@link AutoCloseable} so it can participate in
|
* subscription also implements {@link AutoCloseable} so it can participate in
|
||||||
* try-with-resources blocks.
|
* try-with-resources blocks.
|
||||||
*/
|
*/
|
||||||
public final class MxGatewayActiveAlarmsSubscription implements AutoCloseable {
|
public final class MxGatewayAlarmFeedSubscription implements AutoCloseable {
|
||||||
private final AtomicReference<ClientCallStreamObserver<QueryActiveAlarmsRequest>> requestStream = new AtomicReference<>();
|
private final AtomicReference<ClientCallStreamObserver<StreamAlarmsRequest>> requestStream = new AtomicReference<>();
|
||||||
private final AtomicBoolean cancelled = new AtomicBoolean();
|
private final AtomicBoolean cancelled = new AtomicBoolean();
|
||||||
|
|
||||||
ClientResponseObserver<QueryActiveAlarmsRequest, ActiveAlarmSnapshot> wrap(StreamObserver<ActiveAlarmSnapshot> observer) {
|
ClientResponseObserver<StreamAlarmsRequest, AlarmFeedMessage> wrap(StreamObserver<AlarmFeedMessage> observer) {
|
||||||
return new ClientResponseObserver<>() {
|
return new ClientResponseObserver<>() {
|
||||||
@Override
|
@Override
|
||||||
public void beforeStart(ClientCallStreamObserver<QueryActiveAlarmsRequest> stream) {
|
public void beforeStart(ClientCallStreamObserver<StreamAlarmsRequest> stream) {
|
||||||
requestStream.set(stream);
|
requestStream.set(stream);
|
||||||
if (cancelled.get()) {
|
if (cancelled.get()) {
|
||||||
stream.cancel("client cancelled active-alarms query", null);
|
stream.cancel("client cancelled alarm feed", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onNext(ActiveAlarmSnapshot value) {
|
public void onNext(AlarmFeedMessage value) {
|
||||||
observer.onNext(value);
|
observer.onNext(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +54,9 @@ public final class MxGatewayActiveAlarmsSubscription implements AutoCloseable {
|
|||||||
*/
|
*/
|
||||||
public void cancel() {
|
public void cancel() {
|
||||||
cancelled.set(true);
|
cancelled.set(true);
|
||||||
ClientCallStreamObserver<QueryActiveAlarmsRequest> stream = requestStream.get();
|
ClientCallStreamObserver<StreamAlarmsRequest> stream = requestStream.get();
|
||||||
if (stream != null) {
|
if (stream != null) {
|
||||||
stream.cancel("client cancelled active-alarms query", null);
|
stream.cancel("client cancelled alarm feed", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+15
-11
@@ -10,7 +10,7 @@ import java.util.concurrent.CompletableFuture;
|
|||||||
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||||
@@ -19,7 +19,7 @@ import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
|||||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -328,20 +328,24 @@ public final class MxGatewayClient implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streams a snapshot of all alarms currently Active or ActiveAcked — the
|
* Attaches to the gateway's central alarm feed. The stream opens with one
|
||||||
* gateway's ConditionRefresh equivalent. Used after reconnect to seed
|
* {@code AlarmFeedMessage} per currently-active alarm (the ConditionRefresh
|
||||||
* local Part 9 state.
|
* snapshot), then a single {@code snapshot_complete}, then a
|
||||||
|
* {@code transition} for every subsequent raise / acknowledge / clear.
|
||||||
*
|
*
|
||||||
* @param request the {@code QueryActiveAlarmsRequest}, optionally scoped by
|
* <p>Served by the gateway's always-on alarm monitor — no worker session is
|
||||||
|
* opened — so any number of clients may attach.
|
||||||
|
*
|
||||||
|
* @param request the {@code StreamAlarmsRequest}, optionally scoped by
|
||||||
* alarm-reference prefix
|
* alarm-reference prefix
|
||||||
* @param observer caller-supplied observer that receives snapshots and completion
|
* @param observer caller-supplied observer that receives feed messages and completion
|
||||||
* @return a cancellable subscription handle
|
* @return a cancellable subscription handle
|
||||||
*/
|
*/
|
||||||
public MxGatewayActiveAlarmsSubscription queryActiveAlarms(
|
public MxGatewayAlarmFeedSubscription streamAlarms(
|
||||||
QueryActiveAlarmsRequest request, StreamObserver<ActiveAlarmSnapshot> observer) {
|
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
|
||||||
MxGatewayActiveAlarmsSubscription subscription = new MxGatewayActiveAlarmsSubscription();
|
MxGatewayAlarmFeedSubscription subscription = new MxGatewayAlarmFeedSubscription();
|
||||||
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options)
|
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options)
|
||||||
.queryActiveAlarms(request, subscription.wrap(observer));
|
.streamAlarms(request, subscription.wrap(observer));
|
||||||
return subscription;
|
return subscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+36
-30
@@ -30,10 +30,11 @@ import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
|||||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -57,7 +58,6 @@ final class MxGatewayLowFindingsTests {
|
|||||||
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
|
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
|
||||||
seen.set(request);
|
seen.set(request);
|
||||||
responseObserver.onNext(AcknowledgeAlarmReply.newBuilder()
|
responseObserver.onNext(AcknowledgeAlarmReply.newBuilder()
|
||||||
.setSessionId(request.getSessionId())
|
|
||||||
.setProtocolStatus(ok())
|
.setProtocolStatus(ok())
|
||||||
.setDiagnosticMessage("acked")
|
.setDiagnosticMessage("acked")
|
||||||
.build());
|
.build());
|
||||||
@@ -67,7 +67,6 @@ final class MxGatewayLowFindingsTests {
|
|||||||
|
|
||||||
try (Harness harness = Harness.start(service, "mxgw_keyid_secret", authorization)) {
|
try (Harness harness = Harness.start(service, "mxgw_keyid_secret", authorization)) {
|
||||||
AcknowledgeAlarmReply reply = harness.client().acknowledgeAlarm(AcknowledgeAlarmRequest.newBuilder()
|
AcknowledgeAlarmReply reply = harness.client().acknowledgeAlarm(AcknowledgeAlarmRequest.newBuilder()
|
||||||
.setSessionId("s-1")
|
|
||||||
.setAlarmFullReference("Area1.Pump.PV.HiHi")
|
.setAlarmFullReference("Area1.Pump.PV.HiHi")
|
||||||
.setComment("operator note")
|
.setComment("operator note")
|
||||||
.build());
|
.build());
|
||||||
@@ -84,7 +83,6 @@ final class MxGatewayLowFindingsTests {
|
|||||||
public void acknowledgeAlarm(
|
public void acknowledgeAlarm(
|
||||||
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
|
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
|
||||||
responseObserver.onNext(AcknowledgeAlarmReply.newBuilder()
|
responseObserver.onNext(AcknowledgeAlarmReply.newBuilder()
|
||||||
.setSessionId(request.getSessionId())
|
|
||||||
.setProtocolStatus(ProtocolStatus.newBuilder()
|
.setProtocolStatus(ProtocolStatus.newBuilder()
|
||||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_SESSION_NOT_FOUND))
|
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_SESSION_NOT_FOUND))
|
||||||
.build());
|
.build());
|
||||||
@@ -96,7 +94,7 @@ final class MxGatewayLowFindingsTests {
|
|||||||
assertThrows(
|
assertThrows(
|
||||||
MxGatewayException.class,
|
MxGatewayException.class,
|
||||||
() -> harness.client().acknowledgeAlarm(AcknowledgeAlarmRequest.newBuilder()
|
() -> harness.client().acknowledgeAlarm(AcknowledgeAlarmRequest.newBuilder()
|
||||||
.setSessionId("missing")
|
.setAlarmFullReference("Area1.Pump.PV.HiHi")
|
||||||
.build()));
|
.build()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,7 +106,6 @@ final class MxGatewayLowFindingsTests {
|
|||||||
public void acknowledgeAlarm(
|
public void acknowledgeAlarm(
|
||||||
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
|
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
|
||||||
responseObserver.onNext(AcknowledgeAlarmReply.newBuilder()
|
responseObserver.onNext(AcknowledgeAlarmReply.newBuilder()
|
||||||
.setSessionId(request.getSessionId())
|
|
||||||
.setProtocolStatus(ok())
|
.setProtocolStatus(ok())
|
||||||
.setDiagnosticMessage("async-acked")
|
.setDiagnosticMessage("async-acked")
|
||||||
.build());
|
.build());
|
||||||
@@ -118,7 +115,9 @@ final class MxGatewayLowFindingsTests {
|
|||||||
|
|
||||||
try (Harness harness = Harness.start(service)) {
|
try (Harness harness = Harness.start(service)) {
|
||||||
CompletableFuture<AcknowledgeAlarmReply> future = harness.client()
|
CompletableFuture<AcknowledgeAlarmReply> future = harness.client()
|
||||||
.acknowledgeAlarmAsync(AcknowledgeAlarmRequest.newBuilder().setSessionId("s-2").build());
|
.acknowledgeAlarmAsync(AcknowledgeAlarmRequest.newBuilder()
|
||||||
|
.setAlarmFullReference("Area1.Pump.PV.HiHi")
|
||||||
|
.build());
|
||||||
assertEquals("async-acked", future.get(5, TimeUnit.SECONDS).getDiagnosticMessage());
|
assertEquals("async-acked", future.get(5, TimeUnit.SECONDS).getDiagnosticMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,39 +134,45 @@ final class MxGatewayLowFindingsTests {
|
|||||||
|
|
||||||
try (Harness harness = Harness.start(service)) {
|
try (Harness harness = Harness.start(service)) {
|
||||||
CompletableFuture<AcknowledgeAlarmReply> future = harness.client()
|
CompletableFuture<AcknowledgeAlarmReply> future = harness.client()
|
||||||
.acknowledgeAlarmAsync(AcknowledgeAlarmRequest.newBuilder().setSessionId("s-3").build());
|
.acknowledgeAlarmAsync(AcknowledgeAlarmRequest.newBuilder()
|
||||||
|
.setAlarmFullReference("Area1.Pump.PV.HiHi")
|
||||||
|
.build());
|
||||||
ExecutionException error = assertThrows(
|
ExecutionException error = assertThrows(
|
||||||
ExecutionException.class, () -> future.get(5, TimeUnit.SECONDS));
|
ExecutionException.class, () -> future.get(5, TimeUnit.SECONDS));
|
||||||
assertTrue(error.getCause() instanceof MxGatewayException, () -> String.valueOf(error.getCause()));
|
assertTrue(error.getCause() instanceof MxGatewayException, () -> String.valueOf(error.getCause()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Client.Java-007: QueryActiveAlarms RPC + subscription coverage ---
|
// --- Client.Java-007: StreamAlarms RPC + subscription coverage ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void queryActiveAlarmsDeliversSnapshotsToObserver() throws Exception {
|
void streamAlarmsDeliversFeedMessagesToObserver() throws Exception {
|
||||||
ActiveAlarmSnapshot snapshot = ActiveAlarmSnapshot.newBuilder()
|
AlarmFeedMessage active = AlarmFeedMessage.newBuilder()
|
||||||
|
.setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
|
||||||
.setAlarmFullReference("Area1.Tank.Level.Hi")
|
.setAlarmFullReference("Area1.Tank.Level.Hi")
|
||||||
.setSeverity(800)
|
.setSeverity(800)
|
||||||
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
|
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE))
|
||||||
.build();
|
.build();
|
||||||
|
AlarmFeedMessage snapshotComplete =
|
||||||
|
AlarmFeedMessage.newBuilder().setSnapshotComplete(true).build();
|
||||||
TestService service = new TestService() {
|
TestService service = new TestService() {
|
||||||
@Override
|
@Override
|
||||||
public void queryActiveAlarms(
|
public void streamAlarms(
|
||||||
QueryActiveAlarmsRequest request, StreamObserver<ActiveAlarmSnapshot> responseObserver) {
|
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> responseObserver) {
|
||||||
responseObserver.onNext(snapshot);
|
responseObserver.onNext(active);
|
||||||
|
responseObserver.onNext(snapshotComplete);
|
||||||
responseObserver.onCompleted();
|
responseObserver.onCompleted();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
try (Harness harness = Harness.start(service)) {
|
try (Harness harness = Harness.start(service)) {
|
||||||
List<ActiveAlarmSnapshot> received = new ArrayList<>();
|
List<AlarmFeedMessage> received = new ArrayList<>();
|
||||||
CountDownLatch done = new CountDownLatch(1);
|
CountDownLatch done = new CountDownLatch(1);
|
||||||
harness.client().queryActiveAlarms(
|
harness.client().streamAlarms(
|
||||||
QueryActiveAlarmsRequest.newBuilder().setSessionId("s-4").build(),
|
StreamAlarmsRequest.newBuilder().build(),
|
||||||
new StreamObserver<>() {
|
new StreamObserver<>() {
|
||||||
@Override
|
@Override
|
||||||
public void onNext(ActiveAlarmSnapshot value) {
|
public void onNext(AlarmFeedMessage value) {
|
||||||
received.add(value);
|
received.add(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,18 +187,19 @@ final class MxGatewayLowFindingsTests {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
assertTrue(done.await(5, TimeUnit.SECONDS), "stream should complete");
|
assertTrue(done.await(5, TimeUnit.SECONDS), "stream should complete");
|
||||||
assertEquals(1, received.size());
|
assertEquals(2, received.size());
|
||||||
assertEquals("Area1.Tank.Level.Hi", received.get(0).getAlarmFullReference());
|
assertEquals("Area1.Tank.Level.Hi", received.get(0).getActiveAlarm().getAlarmFullReference());
|
||||||
|
assertTrue(received.get(1).getSnapshotComplete());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void activeAlarmsSubscriptionCancelBeforeBeforeStartCancelsStream() {
|
void alarmFeedSubscriptionCancelBeforeBeforeStartCancelsStream() {
|
||||||
MxGatewayActiveAlarmsSubscription subscription = new MxGatewayActiveAlarmsSubscription();
|
MxGatewayAlarmFeedSubscription subscription = new MxGatewayAlarmFeedSubscription();
|
||||||
ClientResponseObserver<QueryActiveAlarmsRequest, ActiveAlarmSnapshot> observer =
|
ClientResponseObserver<StreamAlarmsRequest, AlarmFeedMessage> observer =
|
||||||
subscription.wrap(new StreamObserver<>() {
|
subscription.wrap(new StreamObserver<>() {
|
||||||
@Override
|
@Override
|
||||||
public void onNext(ActiveAlarmSnapshot value) {
|
public void onNext(AlarmFeedMessage value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -204,13 +210,13 @@ final class MxGatewayLowFindingsTests {
|
|||||||
public void onCompleted() {
|
public void onCompleted() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
RecordingActiveAlarmsRequestStream requestStream = new RecordingActiveAlarmsRequestStream();
|
RecordingAlarmFeedRequestStream requestStream = new RecordingAlarmFeedRequestStream();
|
||||||
|
|
||||||
subscription.cancel();
|
subscription.cancel();
|
||||||
observer.beforeStart(requestStream);
|
observer.beforeStart(requestStream);
|
||||||
|
|
||||||
assertTrue(requestStream.cancelled);
|
assertTrue(requestStream.cancelled);
|
||||||
assertEquals("client cancelled active-alarms query", requestStream.cancelMessage);
|
assertEquals("client cancelled alarm feed", requestStream.cancelMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Client.Java-007: async streamEvents + subscription cancellation ---
|
// --- Client.Java-007: async streamEvents + subscription cancellation ---
|
||||||
@@ -456,8 +462,8 @@ final class MxGatewayLowFindingsTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class RecordingActiveAlarmsRequestStream
|
private static final class RecordingAlarmFeedRequestStream
|
||||||
extends ClientCallStreamObserver<QueryActiveAlarmsRequest> {
|
extends ClientCallStreamObserver<StreamAlarmsRequest> {
|
||||||
private boolean cancelled;
|
private boolean cancelled;
|
||||||
private String cancelMessage;
|
private String cancelMessage;
|
||||||
|
|
||||||
@@ -489,7 +495,7 @@ final class MxGatewayLowFindingsTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onNext(QueryActiveAlarmsRequest value) {
|
public void onNext(StreamAlarmsRequest value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+66
-38
@@ -170,35 +170,35 @@ public final class MxAccessGatewayGrpc {
|
|||||||
return getAcknowledgeAlarmMethod;
|
return getAcknowledgeAlarmMethod;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest,
|
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest,
|
||||||
mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> getQueryActiveAlarmsMethod;
|
mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> getStreamAlarmsMethod;
|
||||||
|
|
||||||
@io.grpc.stub.annotations.RpcMethod(
|
@io.grpc.stub.annotations.RpcMethod(
|
||||||
fullMethodName = SERVICE_NAME + '/' + "QueryActiveAlarms",
|
fullMethodName = SERVICE_NAME + '/' + "StreamAlarms",
|
||||||
requestType = mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest.class,
|
requestType = mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest.class,
|
||||||
responseType = mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot.class,
|
responseType = mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage.class,
|
||||||
methodType = io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
|
methodType = io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
|
||||||
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest,
|
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest,
|
||||||
mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> getQueryActiveAlarmsMethod() {
|
mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> getStreamAlarmsMethod() {
|
||||||
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest, mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> getQueryActiveAlarmsMethod;
|
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest, mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> getStreamAlarmsMethod;
|
||||||
if ((getQueryActiveAlarmsMethod = MxAccessGatewayGrpc.getQueryActiveAlarmsMethod) == null) {
|
if ((getStreamAlarmsMethod = MxAccessGatewayGrpc.getStreamAlarmsMethod) == null) {
|
||||||
synchronized (MxAccessGatewayGrpc.class) {
|
synchronized (MxAccessGatewayGrpc.class) {
|
||||||
if ((getQueryActiveAlarmsMethod = MxAccessGatewayGrpc.getQueryActiveAlarmsMethod) == null) {
|
if ((getStreamAlarmsMethod = MxAccessGatewayGrpc.getStreamAlarmsMethod) == null) {
|
||||||
MxAccessGatewayGrpc.getQueryActiveAlarmsMethod = getQueryActiveAlarmsMethod =
|
MxAccessGatewayGrpc.getStreamAlarmsMethod = getStreamAlarmsMethod =
|
||||||
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest, mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot>newBuilder()
|
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest, mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage>newBuilder()
|
||||||
.setType(io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
|
.setType(io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
|
||||||
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "QueryActiveAlarms"))
|
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "StreamAlarms"))
|
||||||
.setSampledToLocalTracing(true)
|
.setSampledToLocalTracing(true)
|
||||||
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest.getDefaultInstance()))
|
mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest.getDefaultInstance()))
|
||||||
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot.getDefaultInstance()))
|
mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage.getDefaultInstance()))
|
||||||
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("QueryActiveAlarms"))
|
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("StreamAlarms"))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return getQueryActiveAlarmsMethod;
|
return getStreamAlarmsMethod;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -303,10 +303,17 @@ public final class MxAccessGatewayGrpc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Session-less central alarm feed. The stream opens with the current
|
||||||
|
* active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
* `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
* Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
* fan out from the single monitor without opening a worker session.
|
||||||
|
* </pre>
|
||||||
*/
|
*/
|
||||||
default void queryActiveAlarms(mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request,
|
default void streamAlarms(mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest request,
|
||||||
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> responseObserver) {
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> responseObserver) {
|
||||||
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getQueryActiveAlarmsMethod(), responseObserver);
|
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getStreamAlarmsMethod(), responseObserver);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,11 +391,18 @@ public final class MxAccessGatewayGrpc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Session-less central alarm feed. The stream opens with the current
|
||||||
|
* active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
* `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
* Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
* fan out from the single monitor without opening a worker session.
|
||||||
|
* </pre>
|
||||||
*/
|
*/
|
||||||
public void queryActiveAlarms(mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request,
|
public void streamAlarms(mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest request,
|
||||||
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> responseObserver) {
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> responseObserver) {
|
||||||
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
|
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
|
||||||
getChannel().newCall(getQueryActiveAlarmsMethod(), getCallOptions()), request, responseObserver);
|
getChannel().newCall(getStreamAlarmsMethod(), getCallOptions()), request, responseObserver);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,12 +463,19 @@ public final class MxAccessGatewayGrpc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Session-less central alarm feed. The stream opens with the current
|
||||||
|
* active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
* `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
* Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
* fan out from the single monitor without opening a worker session.
|
||||||
|
* </pre>
|
||||||
*/
|
*/
|
||||||
@io.grpc.ExperimentalApi("https://github.com/grpc/grpc-java/issues/10918")
|
@io.grpc.ExperimentalApi("https://github.com/grpc/grpc-java/issues/10918")
|
||||||
public io.grpc.stub.BlockingClientCall<?, mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot>
|
public io.grpc.stub.BlockingClientCall<?, mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage>
|
||||||
queryActiveAlarms(mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request) {
|
streamAlarms(mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest request) {
|
||||||
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
|
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
|
||||||
getChannel(), getQueryActiveAlarmsMethod(), getCallOptions(), request);
|
getChannel(), getStreamAlarmsMethod(), getCallOptions(), request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,11 +535,18 @@ public final class MxAccessGatewayGrpc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Session-less central alarm feed. The stream opens with the current
|
||||||
|
* active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
* `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
* Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
* fan out from the single monitor without opening a worker session.
|
||||||
|
* </pre>
|
||||||
*/
|
*/
|
||||||
public java.util.Iterator<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> queryActiveAlarms(
|
public java.util.Iterator<mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> streamAlarms(
|
||||||
mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request) {
|
mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest request) {
|
||||||
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
|
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
|
||||||
getChannel(), getQueryActiveAlarmsMethod(), getCallOptions(), request);
|
getChannel(), getStreamAlarmsMethod(), getCallOptions(), request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,7 +607,7 @@ public final class MxAccessGatewayGrpc {
|
|||||||
private static final int METHODID_INVOKE = 2;
|
private static final int METHODID_INVOKE = 2;
|
||||||
private static final int METHODID_STREAM_EVENTS = 3;
|
private static final int METHODID_STREAM_EVENTS = 3;
|
||||||
private static final int METHODID_ACKNOWLEDGE_ALARM = 4;
|
private static final int METHODID_ACKNOWLEDGE_ALARM = 4;
|
||||||
private static final int METHODID_QUERY_ACTIVE_ALARMS = 5;
|
private static final int METHODID_STREAM_ALARMS = 5;
|
||||||
|
|
||||||
private static final class MethodHandlers<Req, Resp> implements
|
private static final class MethodHandlers<Req, Resp> implements
|
||||||
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
|
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
|
||||||
@@ -618,9 +646,9 @@ public final class MxAccessGatewayGrpc {
|
|||||||
serviceImpl.acknowledgeAlarm((mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest) request,
|
serviceImpl.acknowledgeAlarm((mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest) request,
|
||||||
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply>) responseObserver);
|
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply>) responseObserver);
|
||||||
break;
|
break;
|
||||||
case METHODID_QUERY_ACTIVE_ALARMS:
|
case METHODID_STREAM_ALARMS:
|
||||||
serviceImpl.queryActiveAlarms((mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest) request,
|
serviceImpl.streamAlarms((mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest) request,
|
||||||
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot>) responseObserver);
|
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage>) responseObserver);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new AssertionError();
|
throw new AssertionError();
|
||||||
@@ -676,12 +704,12 @@ public final class MxAccessGatewayGrpc {
|
|||||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply>(
|
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply>(
|
||||||
service, METHODID_ACKNOWLEDGE_ALARM)))
|
service, METHODID_ACKNOWLEDGE_ALARM)))
|
||||||
.addMethod(
|
.addMethod(
|
||||||
getQueryActiveAlarmsMethod(),
|
getStreamAlarmsMethod(),
|
||||||
io.grpc.stub.ServerCalls.asyncServerStreamingCall(
|
io.grpc.stub.ServerCalls.asyncServerStreamingCall(
|
||||||
new MethodHandlers<
|
new MethodHandlers<
|
||||||
mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest,
|
mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest,
|
||||||
mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot>(
|
mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage>(
|
||||||
service, METHODID_QUERY_ACTIVE_ALARMS)))
|
service, METHODID_STREAM_ALARMS)))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,7 +763,7 @@ public final class MxAccessGatewayGrpc {
|
|||||||
.addMethod(getInvokeMethod())
|
.addMethod(getInvokeMethod())
|
||||||
.addMethod(getStreamEventsMethod())
|
.addMethod(getStreamEventsMethod())
|
||||||
.addMethod(getAcknowledgeAlarmMethod())
|
.addMethod(getAcknowledgeAlarmMethod())
|
||||||
.addMethod(getQueryActiveAlarmsMethod())
|
.addMethod(getStreamAlarmsMethod())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1547
-808
File diff suppressed because it is too large
Load Diff
@@ -166,25 +166,27 @@ class GatewayClient:
|
|||||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
|
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
|
||||||
return reply
|
return reply
|
||||||
|
|
||||||
def query_active_alarms(
|
def stream_alarms(
|
||||||
self,
|
self,
|
||||||
request: pb.QueryActiveAlarmsRequest,
|
request: pb.StreamAlarmsRequest,
|
||||||
*,
|
*,
|
||||||
metadata: Sequence[tuple[str, str]] | None = None,
|
metadata: Sequence[tuple[str, str]] | None = None,
|
||||||
) -> AsyncIterator[pb.ActiveAlarmSnapshot]:
|
) -> AsyncIterator[pb.AlarmFeedMessage]:
|
||||||
"""Stream a snapshot of all alarms currently Active or ActiveAcked.
|
"""Attach to the gateway's central alarm feed.
|
||||||
|
|
||||||
The gateway's ConditionRefresh equivalent. Use after reconnect to seed
|
The stream opens with one ``AlarmFeedMessage`` per currently-active
|
||||||
local Part 9 state, or to reconcile alarms that may have been missed
|
alarm (the ConditionRefresh snapshot), then a single
|
||||||
during a transport blip. Optionally scoped by alarm-reference prefix
|
``snapshot_complete``, then a ``transition`` for every subsequent
|
||||||
(``request.alarm_filter_prefix``) so a partial refresh can target an
|
raise / acknowledge / clear. Served by the gateway's always-on alarm
|
||||||
equipment sub-tree.
|
monitor — no worker session is opened — so any number of clients may
|
||||||
|
attach. Optionally scoped by alarm-reference prefix
|
||||||
|
(``request.alarm_filter_prefix``).
|
||||||
"""
|
"""
|
||||||
kwargs: dict[str, Any] = {"metadata": merge_metadata(self.options.api_key, metadata)}
|
kwargs: dict[str, Any] = {"metadata": merge_metadata(self.options.api_key, metadata)}
|
||||||
if self.options.stream_timeout is not None:
|
if self.options.stream_timeout is not None:
|
||||||
kwargs["timeout"] = self.options.stream_timeout
|
kwargs["timeout"] = self.options.stream_timeout
|
||||||
call = _open_stream(self.raw_stub.QueryActiveAlarms, request, kwargs)
|
call = _open_stream(self.raw_stub.StreamAlarms, request, kwargs)
|
||||||
return _canceling_iterator(call, "query active alarms")
|
return _canceling_iterator(call, "stream alarms")
|
||||||
|
|
||||||
async def _unary(
|
async def _unary(
|
||||||
self,
|
self,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -30,8 +30,7 @@ class MxAccessGatewayStub(object):
|
|||||||
additively only. Never renumber or repurpose an existing field number or
|
additively only. Never renumber or repurpose an existing field number or
|
||||||
enum value. When a field or enum value is removed, add a `reserved` range
|
enum value. When a field or enum value is removed, add a `reserved` range
|
||||||
(and `reserved` name) covering it in the same change so a future editor
|
(and `reserved` name) covering it in the same change so a future editor
|
||||||
cannot accidentally reuse the retired tag. There are no `reserved`
|
cannot accidentally reuse the retired tag.
|
||||||
declarations today because no field or enum value has ever been removed.
|
|
||||||
|
|
||||||
Public client API for MXAccess sessions hosted by the gateway.
|
Public client API for MXAccess sessions hosted by the gateway.
|
||||||
"""
|
"""
|
||||||
@@ -67,10 +66,10 @@ class MxAccessGatewayStub(object):
|
|||||||
request_serializer=mxaccess__gateway__pb2.AcknowledgeAlarmRequest.SerializeToString,
|
request_serializer=mxaccess__gateway__pb2.AcknowledgeAlarmRequest.SerializeToString,
|
||||||
response_deserializer=mxaccess__gateway__pb2.AcknowledgeAlarmReply.FromString,
|
response_deserializer=mxaccess__gateway__pb2.AcknowledgeAlarmReply.FromString,
|
||||||
_registered_method=True)
|
_registered_method=True)
|
||||||
self.QueryActiveAlarms = channel.unary_stream(
|
self.StreamAlarms = channel.unary_stream(
|
||||||
'/mxaccess_gateway.v1.MxAccessGateway/QueryActiveAlarms',
|
'/mxaccess_gateway.v1.MxAccessGateway/StreamAlarms',
|
||||||
request_serializer=mxaccess__gateway__pb2.QueryActiveAlarmsRequest.SerializeToString,
|
request_serializer=mxaccess__gateway__pb2.StreamAlarmsRequest.SerializeToString,
|
||||||
response_deserializer=mxaccess__gateway__pb2.ActiveAlarmSnapshot.FromString,
|
response_deserializer=mxaccess__gateway__pb2.AlarmFeedMessage.FromString,
|
||||||
_registered_method=True)
|
_registered_method=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -79,8 +78,7 @@ class MxAccessGatewayServicer(object):
|
|||||||
additively only. Never renumber or repurpose an existing field number or
|
additively only. Never renumber or repurpose an existing field number or
|
||||||
enum value. When a field or enum value is removed, add a `reserved` range
|
enum value. When a field or enum value is removed, add a `reserved` range
|
||||||
(and `reserved` name) covering it in the same change so a future editor
|
(and `reserved` name) covering it in the same change so a future editor
|
||||||
cannot accidentally reuse the retired tag. There are no `reserved`
|
cannot accidentally reuse the retired tag.
|
||||||
declarations today because no field or enum value has ever been removed.
|
|
||||||
|
|
||||||
Public client API for MXAccess sessions hosted by the gateway.
|
Public client API for MXAccess sessions hosted by the gateway.
|
||||||
"""
|
"""
|
||||||
@@ -115,8 +113,13 @@ class MxAccessGatewayServicer(object):
|
|||||||
context.set_details('Method not implemented!')
|
context.set_details('Method not implemented!')
|
||||||
raise NotImplementedError('Method not implemented!')
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
def QueryActiveAlarms(self, request, context):
|
def StreamAlarms(self, request, context):
|
||||||
"""Missing associated documentation comment in .proto file."""
|
"""Session-less central alarm feed. The stream opens with the current
|
||||||
|
active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
`snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
fan out from the single monitor without opening a worker session.
|
||||||
|
"""
|
||||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
context.set_details('Method not implemented!')
|
context.set_details('Method not implemented!')
|
||||||
raise NotImplementedError('Method not implemented!')
|
raise NotImplementedError('Method not implemented!')
|
||||||
@@ -149,10 +152,10 @@ def add_MxAccessGatewayServicer_to_server(servicer, server):
|
|||||||
request_deserializer=mxaccess__gateway__pb2.AcknowledgeAlarmRequest.FromString,
|
request_deserializer=mxaccess__gateway__pb2.AcknowledgeAlarmRequest.FromString,
|
||||||
response_serializer=mxaccess__gateway__pb2.AcknowledgeAlarmReply.SerializeToString,
|
response_serializer=mxaccess__gateway__pb2.AcknowledgeAlarmReply.SerializeToString,
|
||||||
),
|
),
|
||||||
'QueryActiveAlarms': grpc.unary_stream_rpc_method_handler(
|
'StreamAlarms': grpc.unary_stream_rpc_method_handler(
|
||||||
servicer.QueryActiveAlarms,
|
servicer.StreamAlarms,
|
||||||
request_deserializer=mxaccess__gateway__pb2.QueryActiveAlarmsRequest.FromString,
|
request_deserializer=mxaccess__gateway__pb2.StreamAlarmsRequest.FromString,
|
||||||
response_serializer=mxaccess__gateway__pb2.ActiveAlarmSnapshot.SerializeToString,
|
response_serializer=mxaccess__gateway__pb2.AlarmFeedMessage.SerializeToString,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
generic_handler = grpc.method_handlers_generic_handler(
|
generic_handler = grpc.method_handlers_generic_handler(
|
||||||
@@ -167,8 +170,7 @@ class MxAccessGateway(object):
|
|||||||
additively only. Never renumber or repurpose an existing field number or
|
additively only. Never renumber or repurpose an existing field number or
|
||||||
enum value. When a field or enum value is removed, add a `reserved` range
|
enum value. When a field or enum value is removed, add a `reserved` range
|
||||||
(and `reserved` name) covering it in the same change so a future editor
|
(and `reserved` name) covering it in the same change so a future editor
|
||||||
cannot accidentally reuse the retired tag. There are no `reserved`
|
cannot accidentally reuse the retired tag.
|
||||||
declarations today because no field or enum value has ever been removed.
|
|
||||||
|
|
||||||
Public client API for MXAccess sessions hosted by the gateway.
|
Public client API for MXAccess sessions hosted by the gateway.
|
||||||
"""
|
"""
|
||||||
@@ -309,7 +311,7 @@ class MxAccessGateway(object):
|
|||||||
_registered_method=True)
|
_registered_method=True)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def QueryActiveAlarms(request,
|
def StreamAlarms(request,
|
||||||
target,
|
target,
|
||||||
options=(),
|
options=(),
|
||||||
channel_credentials=None,
|
channel_credentials=None,
|
||||||
@@ -322,9 +324,9 @@ class MxAccessGateway(object):
|
|||||||
return grpc.experimental.unary_stream(
|
return grpc.experimental.unary_stream(
|
||||||
request,
|
request,
|
||||||
target,
|
target,
|
||||||
'/mxaccess_gateway.v1.MxAccessGateway/QueryActiveAlarms',
|
'/mxaccess_gateway.v1.MxAccessGateway/StreamAlarms',
|
||||||
mxaccess__gateway__pb2.QueryActiveAlarmsRequest.SerializeToString,
|
mxaccess__gateway__pb2.StreamAlarmsRequest.SerializeToString,
|
||||||
mxaccess__gateway__pb2.ActiveAlarmSnapshot.FromString,
|
mxaccess__gateway__pb2.AlarmFeedMessage.FromString,
|
||||||
options,
|
options,
|
||||||
channel_credentials,
|
channel_credentials,
|
||||||
insecure,
|
insecure,
|
||||||
|
|||||||
@@ -404,6 +404,40 @@ def stream_events(**kwargs: Any) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("stream-alarms")
|
||||||
|
@gateway_options
|
||||||
|
@click.option("--filter-prefix", default="", help="Alarm-reference prefix filter.")
|
||||||
|
@click.option("--max-messages", default=1, type=int, show_default=True)
|
||||||
|
@click.option("--timeout", default=5.0, type=float, show_default=True)
|
||||||
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||||
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||||
|
def stream_alarms(**kwargs: Any) -> None:
|
||||||
|
"""Stream a bounded number of messages from the gateway's central alarm feed."""
|
||||||
|
|
||||||
|
_run(
|
||||||
|
_stream_alarms(**kwargs),
|
||||||
|
output_json=kwargs["output_json"],
|
||||||
|
secrets=_secrets(kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("acknowledge-alarm")
|
||||||
|
@gateway_options
|
||||||
|
@click.option("--reference", required=True, help="Alarm full reference to acknowledge.")
|
||||||
|
@click.option("--comment", default="", help="Acknowledgement comment.")
|
||||||
|
@click.option("--operator", default="", help="Operator user name.")
|
||||||
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||||
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||||
|
def acknowledge_alarm(**kwargs: Any) -> None:
|
||||||
|
"""Acknowledge an active MXAccess alarm condition (session-less)."""
|
||||||
|
|
||||||
|
_run(
|
||||||
|
_acknowledge_alarm(**kwargs),
|
||||||
|
output_json=kwargs["output_json"],
|
||||||
|
secrets=_secrets(kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@main.command()
|
@main.command()
|
||||||
@gateway_options
|
@gateway_options
|
||||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||||
@@ -779,6 +813,34 @@ async def _stream_events(**kwargs: Any) -> dict[str, Any]:
|
|||||||
return {"events": [_message_dict(event) for event in events]}
|
return {"events": [_message_dict(event) for event in events]}
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_alarms(**kwargs: Any) -> dict[str, Any]:
|
||||||
|
async with await _connect(kwargs) as client:
|
||||||
|
messages = await _collect_alarm_messages(
|
||||||
|
client.stream_alarms(
|
||||||
|
pb.StreamAlarmsRequest(
|
||||||
|
client_correlation_id=kwargs["correlation_id"],
|
||||||
|
alarm_filter_prefix=kwargs["filter_prefix"],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
max_messages=kwargs["max_messages"],
|
||||||
|
timeout=kwargs["timeout"],
|
||||||
|
)
|
||||||
|
return {"messages": [_message_dict(message) for message in messages]}
|
||||||
|
|
||||||
|
|
||||||
|
async def _acknowledge_alarm(**kwargs: Any) -> dict[str, Any]:
|
||||||
|
async with await _connect(kwargs) as client:
|
||||||
|
reply = await client.acknowledge_alarm(
|
||||||
|
pb.AcknowledgeAlarmRequest(
|
||||||
|
client_correlation_id=kwargs["correlation_id"],
|
||||||
|
alarm_full_reference=kwargs["reference"],
|
||||||
|
comment=kwargs["comment"],
|
||||||
|
operator_user=kwargs["operator"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {"rawReply": _message_dict(reply)}
|
||||||
|
|
||||||
|
|
||||||
async def _write(**kwargs: Any) -> dict[str, Any]:
|
async def _write(**kwargs: Any) -> dict[str, Any]:
|
||||||
value = _parse_value(kwargs["value"], kwargs["value_type"])
|
value = _parse_value(kwargs["value"], kwargs["value_type"])
|
||||||
async with await _connect(kwargs) as client:
|
async with await _connect(kwargs) as client:
|
||||||
@@ -936,6 +998,34 @@ async def _collect_events(
|
|||||||
return collected
|
return collected
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_alarm_messages(
|
||||||
|
messages: Any,
|
||||||
|
*,
|
||||||
|
max_messages: int,
|
||||||
|
timeout: float,
|
||||||
|
) -> list[pb.AlarmFeedMessage]:
|
||||||
|
if max_messages > MAX_AGGREGATE_EVENTS:
|
||||||
|
raise click.BadParameter(
|
||||||
|
f"must be less than or equal to {MAX_AGGREGATE_EVENTS}",
|
||||||
|
param_hint="--max-messages",
|
||||||
|
)
|
||||||
|
|
||||||
|
collected: list[pb.AlarmFeedMessage] = []
|
||||||
|
iterator = messages.__aiter__()
|
||||||
|
try:
|
||||||
|
while len(collected) < max_messages:
|
||||||
|
collected.append(await asyncio.wait_for(iterator.__anext__(), timeout=timeout))
|
||||||
|
except StopAsyncIteration:
|
||||||
|
pass
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
close = getattr(iterator, "aclose", None)
|
||||||
|
if close is not None:
|
||||||
|
await close()
|
||||||
|
return collected
|
||||||
|
|
||||||
|
|
||||||
def _parse_value(raw_value: str, value_type: str) -> MxValueInput:
|
def _parse_value(raw_value: str, value_type: str) -> MxValueInput:
|
||||||
normalized = value_type.lower()
|
normalized = value_type.lower()
|
||||||
if normalized == "bool":
|
if normalized == "bool":
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Tests for the AcknowledgeAlarm + QueryActiveAlarms client surface (PR E.3)."""
|
"""Tests for the AcknowledgeAlarm + StreamAlarms client surface."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import grpc
|
import grpc
|
||||||
@@ -18,7 +17,6 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None:
|
|||||||
stub = FakeGatewayStub()
|
stub = FakeGatewayStub()
|
||||||
stub.acknowledge_alarm.replies = [
|
stub.acknowledge_alarm.replies = [
|
||||||
pb.AcknowledgeAlarmReply(
|
pb.AcknowledgeAlarmReply(
|
||||||
session_id="session-1",
|
|
||||||
correlation_id="corr-7",
|
correlation_id="corr-7",
|
||||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||||
status=pb.MxStatusProxy(success=1, category=pb.MX_STATUS_CATEGORY_OK),
|
status=pb.MxStatusProxy(success=1, category=pb.MX_STATUS_CATEGORY_OK),
|
||||||
@@ -31,7 +29,6 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None:
|
|||||||
|
|
||||||
reply = await client.acknowledge_alarm(
|
reply = await client.acknowledge_alarm(
|
||||||
pb.AcknowledgeAlarmRequest(
|
pb.AcknowledgeAlarmRequest(
|
||||||
session_id="session-1",
|
|
||||||
client_correlation_id="corr-7",
|
client_correlation_id="corr-7",
|
||||||
alarm_full_reference="Tank01.Level.HiHi",
|
alarm_full_reference="Tank01.Level.HiHi",
|
||||||
comment="investigating",
|
comment="investigating",
|
||||||
@@ -61,7 +58,6 @@ async def test_acknowledge_alarm_unauthenticated_raises_typed_error() -> None:
|
|||||||
with pytest.raises(MxGatewayAuthenticationError):
|
with pytest.raises(MxGatewayAuthenticationError):
|
||||||
await client.acknowledge_alarm(
|
await client.acknowledge_alarm(
|
||||||
pb.AcknowledgeAlarmRequest(
|
pb.AcknowledgeAlarmRequest(
|
||||||
session_id="session-1",
|
|
||||||
alarm_full_reference="Tank01.Level.HiHi",
|
alarm_full_reference="Tank01.Level.HiHi",
|
||||||
comment="",
|
comment="",
|
||||||
operator_user="alice",
|
operator_user="alice",
|
||||||
@@ -81,7 +77,6 @@ async def test_acknowledge_alarm_permission_denied_raises_typed_error() -> None:
|
|||||||
with pytest.raises(MxGatewayAuthorizationError):
|
with pytest.raises(MxGatewayAuthorizationError):
|
||||||
await client.acknowledge_alarm(
|
await client.acknowledge_alarm(
|
||||||
pb.AcknowledgeAlarmRequest(
|
pb.AcknowledgeAlarmRequest(
|
||||||
session_id="session-1",
|
|
||||||
alarm_full_reference="Tank01.Level.HiHi",
|
alarm_full_reference="Tank01.Level.HiHi",
|
||||||
comment="",
|
comment="",
|
||||||
operator_user="alice",
|
operator_user="alice",
|
||||||
@@ -90,84 +85,90 @@ async def test_acknowledge_alarm_permission_denied_raises_typed_error() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_query_active_alarms_streams_snapshots() -> None:
|
async def test_stream_alarms_streams_snapshot_then_snapshot_complete() -> None:
|
||||||
snapshots = [
|
messages = [
|
||||||
pb.ActiveAlarmSnapshot(
|
pb.AlarmFeedMessage(
|
||||||
|
active_alarm=pb.ActiveAlarmSnapshot(
|
||||||
alarm_full_reference="Tank01.Level.HiHi",
|
alarm_full_reference="Tank01.Level.HiHi",
|
||||||
current_state=pb.ALARM_CONDITION_STATE_ACTIVE,
|
current_state=pb.ALARM_CONDITION_STATE_ACTIVE,
|
||||||
severity=750,
|
severity=750,
|
||||||
),
|
),
|
||||||
pb.ActiveAlarmSnapshot(
|
),
|
||||||
|
pb.AlarmFeedMessage(
|
||||||
|
active_alarm=pb.ActiveAlarmSnapshot(
|
||||||
alarm_full_reference="Tank02.Level.HiHi",
|
alarm_full_reference="Tank02.Level.HiHi",
|
||||||
current_state=pb.ALARM_CONDITION_STATE_ACTIVE_ACKED,
|
current_state=pb.ALARM_CONDITION_STATE_ACTIVE_ACKED,
|
||||||
severity=750,
|
severity=750,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
pb.AlarmFeedMessage(snapshot_complete=True),
|
||||||
]
|
]
|
||||||
stream = FakeSnapshotStream(snapshots)
|
stream = FakeAlarmFeedStream(messages)
|
||||||
stub = FakeGatewayStub(snapshot_stream=stream)
|
stub = FakeGatewayStub(alarm_feed_stream=stream)
|
||||||
client = await GatewayClient.connect(
|
client = await GatewayClient.connect(
|
||||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||||
stub=stub,
|
stub=stub,
|
||||||
)
|
)
|
||||||
|
|
||||||
received: list[pb.ActiveAlarmSnapshot] = []
|
received: list[pb.AlarmFeedMessage] = []
|
||||||
async for snapshot in client.query_active_alarms(
|
async for message in client.stream_alarms(pb.StreamAlarmsRequest()):
|
||||||
pb.QueryActiveAlarmsRequest(session_id="session-1"),
|
received.append(message)
|
||||||
):
|
|
||||||
received.append(snapshot)
|
|
||||||
|
|
||||||
assert len(received) == 2
|
assert len(received) == 3
|
||||||
assert received[0].alarm_full_reference == "Tank01.Level.HiHi"
|
assert received[0].active_alarm.alarm_full_reference == "Tank01.Level.HiHi"
|
||||||
assert received[0].current_state == pb.ALARM_CONDITION_STATE_ACTIVE
|
assert received[0].active_alarm.current_state == pb.ALARM_CONDITION_STATE_ACTIVE
|
||||||
assert received[1].current_state == pb.ALARM_CONDITION_STATE_ACTIVE_ACKED
|
assert received[1].active_alarm.current_state == pb.ALARM_CONDITION_STATE_ACTIVE_ACKED
|
||||||
assert stub.query_metadata == (("authorization", "Bearer mxgw_test_secret"),)
|
assert received[2].snapshot_complete is True
|
||||||
|
assert stub.stream_metadata == (("authorization", "Bearer mxgw_test_secret"),)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_query_active_alarms_passes_filter_prefix() -> None:
|
async def test_stream_alarms_passes_filter_prefix() -> None:
|
||||||
stream = FakeSnapshotStream([])
|
stream = FakeAlarmFeedStream([])
|
||||||
stub = FakeGatewayStub(snapshot_stream=stream)
|
stub = FakeGatewayStub(alarm_feed_stream=stream)
|
||||||
client = await GatewayClient.connect(
|
client = await GatewayClient.connect(
|
||||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||||
stub=stub,
|
stub=stub,
|
||||||
)
|
)
|
||||||
|
|
||||||
iterator = client.query_active_alarms(
|
iterator = client.stream_alarms(
|
||||||
pb.QueryActiveAlarmsRequest(session_id="session-1", alarm_filter_prefix="Tank01."),
|
pb.StreamAlarmsRequest(alarm_filter_prefix="Tank01."),
|
||||||
)
|
)
|
||||||
# Drain to trigger the stub call.
|
# Drain to trigger the stub call.
|
||||||
async for _ in iterator:
|
async for _ in iterator:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
assert stub.query_request is not None
|
assert stub.stream_request is not None
|
||||||
assert stub.query_request.alarm_filter_prefix == "Tank01."
|
assert stub.stream_request.alarm_filter_prefix == "Tank01."
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_query_active_alarms_cancels_underlying_stream_on_close() -> None:
|
async def test_stream_alarms_cancels_underlying_stream_on_close() -> None:
|
||||||
snapshots = [
|
messages = [
|
||||||
pb.ActiveAlarmSnapshot(
|
pb.AlarmFeedMessage(
|
||||||
|
active_alarm=pb.ActiveAlarmSnapshot(
|
||||||
alarm_full_reference="Tank01.Level.HiHi",
|
alarm_full_reference="Tank01.Level.HiHi",
|
||||||
current_state=pb.ALARM_CONDITION_STATE_ACTIVE,
|
current_state=pb.ALARM_CONDITION_STATE_ACTIVE,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
stream = FakeSnapshotStream(snapshots)
|
stream = FakeAlarmFeedStream(messages)
|
||||||
stub = FakeGatewayStub(snapshot_stream=stream)
|
stub = FakeGatewayStub(alarm_feed_stream=stream)
|
||||||
client = await GatewayClient.connect(
|
client = await GatewayClient.connect(
|
||||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||||
stub=stub,
|
stub=stub,
|
||||||
)
|
)
|
||||||
|
|
||||||
iterator = client.query_active_alarms(pb.QueryActiveAlarmsRequest(session_id="session-1"))
|
iterator = client.stream_alarms(pb.StreamAlarmsRequest())
|
||||||
first = await anext(iterator)
|
first = await anext(iterator)
|
||||||
await iterator.aclose()
|
await iterator.aclose()
|
||||||
|
|
||||||
assert first.alarm_full_reference == "Tank01.Level.HiHi"
|
assert first.active_alarm.alarm_full_reference == "Tank01.Level.HiHi"
|
||||||
assert stream.cancelled
|
assert stream.cancelled
|
||||||
|
|
||||||
|
|
||||||
class FakeGatewayStub:
|
class FakeGatewayStub:
|
||||||
def __init__(self, snapshot_stream: "FakeSnapshotStream | None" = None) -> None:
|
def __init__(self, alarm_feed_stream: "FakeAlarmFeedStream | None" = None) -> None:
|
||||||
self.open_session = FakeUnary(
|
self.open_session = FakeUnary(
|
||||||
[
|
[
|
||||||
pb.OpenSessionReply(
|
pb.OpenSessionReply(
|
||||||
@@ -179,19 +180,19 @@ class FakeGatewayStub:
|
|||||||
self.acknowledge_alarm = FakeUnary([])
|
self.acknowledge_alarm = FakeUnary([])
|
||||||
self.OpenSession = self.open_session
|
self.OpenSession = self.open_session
|
||||||
self.AcknowledgeAlarm = self.acknowledge_alarm
|
self.AcknowledgeAlarm = self.acknowledge_alarm
|
||||||
self._snapshot_stream = snapshot_stream or FakeSnapshotStream([])
|
self._alarm_feed_stream = alarm_feed_stream or FakeAlarmFeedStream([])
|
||||||
self.query_request: pb.QueryActiveAlarmsRequest | None = None
|
self.stream_request: pb.StreamAlarmsRequest | None = None
|
||||||
self.query_metadata: tuple[tuple[str, str], ...] | None = None
|
self.stream_metadata: tuple[tuple[str, str], ...] | None = None
|
||||||
|
|
||||||
def QueryActiveAlarms(
|
def StreamAlarms(
|
||||||
self,
|
self,
|
||||||
request: pb.QueryActiveAlarmsRequest,
|
request: pb.StreamAlarmsRequest,
|
||||||
*,
|
*,
|
||||||
metadata: tuple[tuple[str, str], ...],
|
metadata: tuple[tuple[str, str], ...],
|
||||||
) -> "FakeSnapshotStream":
|
) -> "FakeAlarmFeedStream":
|
||||||
self.query_request = request
|
self.stream_request = request
|
||||||
self.query_metadata = metadata
|
self.stream_metadata = metadata
|
||||||
return self._snapshot_stream
|
return self._alarm_feed_stream
|
||||||
|
|
||||||
|
|
||||||
class FakeUnary:
|
class FakeUnary:
|
||||||
@@ -214,18 +215,18 @@ class FakeUnary:
|
|||||||
return self.replies.pop(0)
|
return self.replies.pop(0)
|
||||||
|
|
||||||
|
|
||||||
class FakeSnapshotStream:
|
class FakeAlarmFeedStream:
|
||||||
def __init__(self, snapshots: list[pb.ActiveAlarmSnapshot]) -> None:
|
def __init__(self, messages: list[pb.AlarmFeedMessage]) -> None:
|
||||||
self._snapshots = list(snapshots)
|
self._messages = list(messages)
|
||||||
self.cancelled = False
|
self.cancelled = False
|
||||||
|
|
||||||
def __aiter__(self) -> "FakeSnapshotStream":
|
def __aiter__(self) -> "FakeAlarmFeedStream":
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __anext__(self) -> pb.ActiveAlarmSnapshot:
|
async def __anext__(self) -> pb.AlarmFeedMessage:
|
||||||
if not self._snapshots:
|
if not self._messages:
|
||||||
raise StopAsyncIteration
|
raise StopAsyncIteration
|
||||||
return self._snapshots.pop(0)
|
return self._messages.pop(0)
|
||||||
|
|
||||||
def cancel(self) -> None:
|
def cancel(self) -> None:
|
||||||
self.cancelled = True
|
self.cancelled = True
|
||||||
|
|||||||
@@ -52,6 +52,28 @@ def test_write_parser_rejects_unknown_value_type() -> None:
|
|||||||
assert "unsupported value type" in result.output
|
assert "unsupported value type" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_alarms_is_registered() -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
result = runner.invoke(main, ["stream-alarms", "--help"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "--filter-prefix" in result.output
|
||||||
|
assert "--max-messages" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_acknowledge_alarm_requires_reference() -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
["acknowledge-alarm", "--api-key", "mxgw_test_secret", "--json"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "--reference" in result.output
|
||||||
|
|
||||||
|
|
||||||
def test_cli_error_output_redacts_api_key() -> None:
|
def test_cli_error_output_redacts_api_key() -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Regression tests for Client.Python-003: stream timeout-kwarg fallback.
|
"""Regression tests for Client.Python-003: stream timeout-kwarg fallback.
|
||||||
|
|
||||||
`stream_events_raw` and `query_active_alarms` must tolerate a fake/older stub
|
`stream_events_raw` and `stream_alarms` must tolerate a fake/older stub
|
||||||
that does not accept a ``timeout`` keyword argument, matching the fallback
|
that does not accept a ``timeout`` keyword argument, matching the fallback
|
||||||
already present in `galaxy.watch_deploy_events` and the unary `_unary` helper.
|
already present in `galaxy.watch_deploy_events` and the unary `_unary` helper.
|
||||||
"""
|
"""
|
||||||
@@ -51,9 +51,9 @@ class _NoTimeoutStubStreamEvents:
|
|||||||
self.StreamEvents = stream
|
self.StreamEvents = stream
|
||||||
|
|
||||||
|
|
||||||
class _NoTimeoutStubQueryAlarms:
|
class _NoTimeoutStubStreamAlarms:
|
||||||
def __init__(self, stream: _NoTimeoutStream) -> None:
|
def __init__(self, stream: _NoTimeoutStream) -> None:
|
||||||
self.QueryActiveAlarms = stream
|
self.StreamAlarms = stream
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -78,24 +78,30 @@ async def test_stream_events_raw_falls_back_when_stub_rejects_timeout() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_query_active_alarms_falls_back_when_stub_rejects_timeout() -> None:
|
async def test_stream_alarms_falls_back_when_stub_rejects_timeout() -> None:
|
||||||
stream = _NoTimeoutStream(
|
stream = _NoTimeoutStream(
|
||||||
[pb.ActiveAlarmSnapshot(alarm_full_reference="Tank01.Level.HiHi")],
|
[
|
||||||
|
pb.AlarmFeedMessage(
|
||||||
|
active_alarm=pb.ActiveAlarmSnapshot(
|
||||||
|
alarm_full_reference="Tank01.Level.HiHi",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
client = await GatewayClient.connect(
|
client = await GatewayClient.connect(
|
||||||
ClientOptions(endpoint="fake", plaintext=True, stream_timeout=5.0),
|
ClientOptions(endpoint="fake", plaintext=True, stream_timeout=5.0),
|
||||||
stub=_NoTimeoutStubQueryAlarms(stream),
|
stub=_NoTimeoutStubStreamAlarms(stream),
|
||||||
)
|
)
|
||||||
|
|
||||||
received = [
|
received = [
|
||||||
snapshot
|
message
|
||||||
async for snapshot in client.query_active_alarms(
|
async for message in client.stream_alarms(
|
||||||
pb.QueryActiveAlarmsRequest(session_id="session-1"),
|
pb.StreamAlarmsRequest(),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
assert len(received) == 1
|
assert len(received) == 1
|
||||||
assert received[0].alarm_full_reference == "Tank01.Level.HiHi"
|
assert received[0].active_alarm.alarm_full_reference == "Tank01.Level.HiHi"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
|
|||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use mxgateway_client::generated::galaxy_repository::v1::DeployEvent;
|
use mxgateway_client::generated::galaxy_repository::v1::DeployEvent;
|
||||||
use mxgateway_client::generated::mxaccess_gateway::v1::{
|
use mxgateway_client::generated::mxaccess_gateway::v1::{
|
||||||
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily,
|
alarm_feed_message, AcknowledgeAlarmRequest, AlarmFeedMessage, CloseSessionRequest, MxCommand,
|
||||||
MxValue as ProtoMxValue, OpenSessionRequest, PingCommand, StreamEventsRequest, Write2BulkEntry,
|
MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily, MxValue as ProtoMxValue,
|
||||||
|
OpenSessionRequest, PingCommand, StreamAlarmsRequest, StreamEventsRequest, Write2BulkEntry,
|
||||||
WriteBulkEntry, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
|
WriteBulkEntry, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
|
||||||
};
|
};
|
||||||
use mxgateway_client::{
|
use mxgateway_client::{
|
||||||
@@ -272,6 +273,24 @@ enum Command {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
jsonl: bool,
|
jsonl: bool,
|
||||||
},
|
},
|
||||||
|
/// Attach to the gateway's session-less central alarm feed. The stream
|
||||||
|
/// opens with one `active_alarm` per currently-active alarm, then a
|
||||||
|
/// single `snapshot_complete`, then a `transition` for every subsequent
|
||||||
|
/// raise / acknowledge / clear.
|
||||||
|
StreamAlarms {
|
||||||
|
#[command(flatten)]
|
||||||
|
connection: ConnectionArgs,
|
||||||
|
/// Optional alarm-reference prefix scoping the feed to an equipment
|
||||||
|
/// sub-tree. Omit to stream every active alarm.
|
||||||
|
#[arg(long)]
|
||||||
|
filter_prefix: Option<String>,
|
||||||
|
#[arg(long, default_value_t = 1)]
|
||||||
|
max_events: usize,
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
jsonl: bool,
|
||||||
|
},
|
||||||
Write {
|
Write {
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
connection: ConnectionArgs,
|
connection: ConnectionArgs,
|
||||||
@@ -310,6 +329,20 @@ enum Command {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
},
|
},
|
||||||
|
/// Acknowledge an active MXAccess alarm condition through the gateway's
|
||||||
|
/// session-less AcknowledgeAlarm RPC.
|
||||||
|
AcknowledgeAlarm {
|
||||||
|
#[command(flatten)]
|
||||||
|
connection: ConnectionArgs,
|
||||||
|
#[arg(long)]
|
||||||
|
reference: String,
|
||||||
|
#[arg(long, default_value = "")]
|
||||||
|
comment: String,
|
||||||
|
#[arg(long, default_value = "")]
|
||||||
|
operator: String,
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
Smoke {
|
Smoke {
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
connection: ConnectionArgs,
|
connection: ConnectionArgs,
|
||||||
@@ -432,13 +465,32 @@ enum CliValueType {
|
|||||||
String,
|
String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
/// Entry point. The real work runs on a dedicated thread with a large stack:
|
||||||
async fn main() -> ExitCode {
|
/// clap's derive-generated argument parser is deeply recursive, and in debug
|
||||||
|
/// builds (no inlining) parsing the `Command` enum can exhaust the default
|
||||||
|
/// 8 MiB main-thread stack as the enum grows. A 32 MiB worker stack keeps the
|
||||||
|
/// CLI robust regardless of build profile or future subcommand growth.
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let worker = std::thread::Builder::new()
|
||||||
|
.name("mxgw-cli".to_owned())
|
||||||
|
.stack_size(32 * 1024 * 1024)
|
||||||
|
.spawn(run)
|
||||||
|
.expect("failed to spawn the CLI worker thread");
|
||||||
|
worker.join().expect("the CLI worker thread panicked")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> ExitCode {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let result = match cli.command {
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("failed to build the Tokio runtime");
|
||||||
|
let result = runtime.block_on(async {
|
||||||
|
match cli.command {
|
||||||
Command::Batch => run_batch().await,
|
Command::Batch => run_batch().await,
|
||||||
command => dispatch(command).await,
|
command => dispatch(command).await,
|
||||||
};
|
}
|
||||||
|
});
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => ExitCode::SUCCESS,
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -788,6 +840,52 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
|||||||
println!("{}", json!({ "eventCount": event_count, "events": events }));
|
println!("{}", json!({ "eventCount": event_count, "events": events }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Command::StreamAlarms {
|
||||||
|
connection,
|
||||||
|
filter_prefix,
|
||||||
|
max_events,
|
||||||
|
json,
|
||||||
|
jsonl,
|
||||||
|
} => {
|
||||||
|
if max_events > MAX_AGGREGATE_EVENTS {
|
||||||
|
return Err(Error::InvalidArgument {
|
||||||
|
name: "max-events".to_owned(),
|
||||||
|
detail: format!("must be less than or equal to {MAX_AGGREGATE_EVENTS}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = connect(connection).await?;
|
||||||
|
let mut stream = client
|
||||||
|
.stream_alarms(StreamAlarmsRequest {
|
||||||
|
client_correlation_id: mxgateway_client::next_correlation_id(
|
||||||
|
"cli-stream-alarms",
|
||||||
|
),
|
||||||
|
alarm_filter_prefix: filter_prefix.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let mut messages: Vec<Value> = Vec::new();
|
||||||
|
let mut message_count = 0usize;
|
||||||
|
while message_count < max_events {
|
||||||
|
let Some(message) = stream.next().await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let message = message?;
|
||||||
|
message_count += 1;
|
||||||
|
if jsonl {
|
||||||
|
println!("{}", alarm_feed_message_to_json(&message));
|
||||||
|
} else if json {
|
||||||
|
messages.push(alarm_feed_message_to_json(&message));
|
||||||
|
} else {
|
||||||
|
println!("{}", alarm_feed_message_summary(&message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if json {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
json!({ "messageCount": message_count, "messages": messages })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Command::Write {
|
Command::Write {
|
||||||
connection,
|
connection,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -832,6 +930,26 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
|||||||
.await?;
|
.await?;
|
||||||
print_ok("write2", json);
|
print_ok("write2", json);
|
||||||
}
|
}
|
||||||
|
Command::AcknowledgeAlarm {
|
||||||
|
connection,
|
||||||
|
reference,
|
||||||
|
comment,
|
||||||
|
operator,
|
||||||
|
json,
|
||||||
|
} => {
|
||||||
|
let client = connect(connection).await?;
|
||||||
|
let reply = client
|
||||||
|
.acknowledge_alarm(AcknowledgeAlarmRequest {
|
||||||
|
client_correlation_id: mxgateway_client::next_correlation_id(
|
||||||
|
"cli-acknowledge-alarm",
|
||||||
|
),
|
||||||
|
alarm_full_reference: reference,
|
||||||
|
comment,
|
||||||
|
operator_user: operator,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
print_acknowledge_alarm_reply(&reply, json);
|
||||||
|
}
|
||||||
Command::Galaxy(galaxy_command) => run_galaxy(galaxy_command).await?,
|
Command::Galaxy(galaxy_command) => run_galaxy(galaxy_command).await?,
|
||||||
Command::Smoke {
|
Command::Smoke {
|
||||||
connection,
|
connection,
|
||||||
@@ -1533,6 +1651,113 @@ fn print_deploy_event(event: &DeployEvent, use_json: bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render a streamed [`AlarmFeedMessage`] as a terse one-line summary that
|
||||||
|
/// distinguishes the three `payload` oneof cases.
|
||||||
|
fn alarm_feed_message_summary(message: &AlarmFeedMessage) -> String {
|
||||||
|
match &message.payload {
|
||||||
|
Some(alarm_feed_message::Payload::ActiveAlarm(snapshot)) => {
|
||||||
|
format!(
|
||||||
|
"active-alarm {} state={}",
|
||||||
|
snapshot.alarm_full_reference,
|
||||||
|
AlarmEnumName::condition_state(snapshot.current_state)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Some(alarm_feed_message::Payload::SnapshotComplete(complete)) => {
|
||||||
|
format!("snapshot-complete {complete}")
|
||||||
|
}
|
||||||
|
Some(alarm_feed_message::Payload::Transition(transition)) => {
|
||||||
|
format!(
|
||||||
|
"transition {} kind={}",
|
||||||
|
transition.alarm_full_reference,
|
||||||
|
AlarmEnumName::transition_kind(transition.transition_kind)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => "(empty)".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a streamed [`AlarmFeedMessage`] as a JSON object whose single
|
||||||
|
/// top-level key names the active `payload` oneof case, mirroring the
|
||||||
|
/// protobuf-JSON the .NET/Go/Java/Python CLIs emit.
|
||||||
|
fn alarm_feed_message_to_json(message: &AlarmFeedMessage) -> Value {
|
||||||
|
match &message.payload {
|
||||||
|
Some(alarm_feed_message::Payload::ActiveAlarm(snapshot)) => json!({
|
||||||
|
"activeAlarm": {
|
||||||
|
"alarmFullReference": snapshot.alarm_full_reference,
|
||||||
|
"sourceObjectReference": snapshot.source_object_reference,
|
||||||
|
"alarmTypeName": snapshot.alarm_type_name,
|
||||||
|
"severity": snapshot.severity,
|
||||||
|
"currentState": AlarmEnumName::condition_state(snapshot.current_state),
|
||||||
|
"category": snapshot.category,
|
||||||
|
"description": snapshot.description,
|
||||||
|
"operatorUser": snapshot.operator_user,
|
||||||
|
"operatorComment": snapshot.operator_comment,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Some(alarm_feed_message::Payload::SnapshotComplete(complete)) => json!({
|
||||||
|
"snapshotComplete": complete,
|
||||||
|
}),
|
||||||
|
Some(alarm_feed_message::Payload::Transition(transition)) => json!({
|
||||||
|
"transition": {
|
||||||
|
"alarmFullReference": transition.alarm_full_reference,
|
||||||
|
"sourceObjectReference": transition.source_object_reference,
|
||||||
|
"alarmTypeName": transition.alarm_type_name,
|
||||||
|
"transitionKind": AlarmEnumName::transition_kind(transition.transition_kind),
|
||||||
|
"severity": transition.severity,
|
||||||
|
"operatorUser": transition.operator_user,
|
||||||
|
"operatorComment": transition.operator_comment,
|
||||||
|
"category": transition.category,
|
||||||
|
"description": transition.description,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
None => Value::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tiny namespace for alarm-enum name lookups used by the alarm-feed
|
||||||
|
/// renderers; keeps the proto-enum imports off the `main.rs` top level.
|
||||||
|
struct AlarmEnumName;
|
||||||
|
|
||||||
|
impl AlarmEnumName {
|
||||||
|
fn condition_state(value: i32) -> String {
|
||||||
|
use mxgateway_client::generated::mxaccess_gateway::v1::AlarmConditionState;
|
||||||
|
AlarmConditionState::try_from(value)
|
||||||
|
.map(|state| state.as_str_name().to_owned())
|
||||||
|
.unwrap_or_else(|_| value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transition_kind(value: i32) -> String {
|
||||||
|
use mxgateway_client::generated::mxaccess_gateway::v1::AlarmTransitionKind;
|
||||||
|
AlarmTransitionKind::try_from(value)
|
||||||
|
.map(|kind| kind.as_str_name().to_owned())
|
||||||
|
.unwrap_or_else(|_| value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render an [`AcknowledgeAlarmReply`] as a terse line or a JSON document.
|
||||||
|
fn print_acknowledge_alarm_reply(
|
||||||
|
reply: &mxgateway_client::generated::mxaccess_gateway::v1::AcknowledgeAlarmReply,
|
||||||
|
use_json: bool,
|
||||||
|
) {
|
||||||
|
if use_json {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
json!({
|
||||||
|
"operation": "acknowledge-alarm",
|
||||||
|
"correlationId": reply.correlation_id,
|
||||||
|
"protocolStatus": reply.protocol_status.as_ref().map(|status| json!({
|
||||||
|
"code": status.code,
|
||||||
|
"message": status.message,
|
||||||
|
})),
|
||||||
|
"hresult": reply.hresult,
|
||||||
|
"diagnosticMessage": reply.diagnostic_message,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("acknowledge-alarm completed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Render a streamed [`MxEvent`] as a JSON object. The scalar value is
|
/// Render a streamed [`MxEvent`] as a JSON object. The scalar value is
|
||||||
/// projected into protojson-style `*Value` keys so the cross-language e2e
|
/// projected into protojson-style `*Value` keys so the cross-language e2e
|
||||||
/// matrix can extract and compare event values uniformly across all five
|
/// matrix can extract and compare event values uniformly across all five
|
||||||
@@ -1793,6 +2018,47 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_stream_alarms_command() {
|
||||||
|
let parsed = Cli::try_parse_from([
|
||||||
|
"mxgw",
|
||||||
|
"stream-alarms",
|
||||||
|
"--filter-prefix",
|
||||||
|
"Tank01",
|
||||||
|
"--max-events",
|
||||||
|
"3",
|
||||||
|
"--json",
|
||||||
|
]);
|
||||||
|
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_stream_alarms_command_without_filter_prefix() {
|
||||||
|
let parsed = Cli::try_parse_from(["mxgw", "stream-alarms"]);
|
||||||
|
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_acknowledge_alarm_command() {
|
||||||
|
let parsed = Cli::try_parse_from([
|
||||||
|
"mxgw",
|
||||||
|
"acknowledge-alarm",
|
||||||
|
"--reference",
|
||||||
|
"Tank01.Level.HiHi",
|
||||||
|
"--comment",
|
||||||
|
"ack from cli",
|
||||||
|
"--operator",
|
||||||
|
"operator1",
|
||||||
|
]);
|
||||||
|
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acknowledge_alarm_requires_reference() {
|
||||||
|
let parsed = Cli::try_parse_from(["mxgw", "acknowledge-alarm"]);
|
||||||
|
assert!(parsed.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_galaxy_watch_command_with_last_seen_and_max_events() {
|
fn parses_galaxy_watch_command_with_last_seen_and_max_events() {
|
||||||
let parsed = Cli::try_parse_from([
|
let parsed = Cli::try_parse_from([
|
||||||
|
|||||||
+19
-18
@@ -16,9 +16,9 @@ use crate::auth::AuthInterceptor;
|
|||||||
use crate::error::{ensure_command_success, ensure_protocol_success, Error};
|
use crate::error::{ensure_command_success, ensure_protocol_success, Error};
|
||||||
use crate::generated::mxaccess_gateway::v1::mx_access_gateway_client::MxAccessGatewayClient;
|
use crate::generated::mxaccess_gateway::v1::mx_access_gateway_client::MxAccessGatewayClient;
|
||||||
use crate::generated::mxaccess_gateway::v1::{
|
use crate::generated::mxaccess_gateway::v1::{
|
||||||
AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot, CloseSessionReply,
|
AcknowledgeAlarmReply, AcknowledgeAlarmRequest, AlarmFeedMessage, CloseSessionReply,
|
||||||
CloseSessionRequest, MxCommandReply, MxCommandRequest, MxEvent, OpenSessionReply,
|
CloseSessionRequest, MxCommandReply, MxCommandRequest, MxEvent, OpenSessionReply,
|
||||||
OpenSessionRequest, QueryActiveAlarmsRequest, StreamEventsRequest,
|
OpenSessionRequest, StreamAlarmsRequest, StreamEventsRequest,
|
||||||
};
|
};
|
||||||
use crate::options::ClientOptions;
|
use crate::options::ClientOptions;
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
@@ -33,11 +33,11 @@ pub type RawGatewayClient = MxAccessGatewayClient<InterceptedService<Channel, Au
|
|||||||
pub type EventStream =
|
pub type EventStream =
|
||||||
std::pin::Pin<Box<dyn futures_core::Stream<Item = Result<MxEvent, Error>> + Send + 'static>>;
|
std::pin::Pin<Box<dyn futures_core::Stream<Item = Result<MxEvent, Error>> + Send + 'static>>;
|
||||||
|
|
||||||
/// Pinned, boxed [`ActiveAlarmSnapshot`] stream returned by
|
/// Pinned, boxed [`AlarmFeedMessage`] stream returned by
|
||||||
/// [`GatewayClient::query_active_alarms`]. Errors are pre-mapped from
|
/// [`GatewayClient::stream_alarms`]. Errors are pre-mapped from
|
||||||
/// `tonic::Status` to [`Error`]; dropping the stream cancels the call.
|
/// `tonic::Status` to [`Error`]; dropping the stream cancels the call.
|
||||||
pub type ActiveAlarmStream = std::pin::Pin<
|
pub type AlarmFeedStream = std::pin::Pin<
|
||||||
Box<dyn futures_core::Stream<Item = Result<ActiveAlarmSnapshot, Error>> + Send + 'static>,
|
Box<dyn futures_core::Stream<Item = Result<AlarmFeedMessage, Error>> + Send + 'static>,
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/// Thin async wrapper around the generated gateway client.
|
/// Thin async wrapper around the generated gateway client.
|
||||||
@@ -227,26 +227,27 @@ impl GatewayClient {
|
|||||||
Ok(reply)
|
Ok(reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the server-streaming `QueryActiveAlarms` RPC — the gateway's
|
/// Attach to the gateway's central `StreamAlarms` feed.
|
||||||
/// ConditionRefresh equivalent.
|
|
||||||
///
|
///
|
||||||
/// The returned [`ActiveAlarmStream`] yields one [`ActiveAlarmSnapshot`]
|
/// The returned [`AlarmFeedStream`] opens with one [`AlarmFeedMessage`]
|
||||||
/// per currently-active alarm. Dropping the stream cancels the gRPC call
|
/// per currently-active alarm (the ConditionRefresh snapshot), then a
|
||||||
/// cooperatively. Optional alarm-reference prefix scoping
|
/// single `snapshot_complete`, then a `transition` for every subsequent
|
||||||
/// (`request.alarm_filter_prefix`) limits the stream to a sub-tree.
|
/// raise / acknowledge / clear. It is served by the gateway's always-on
|
||||||
|
/// alarm monitor — no worker session is opened — so any number of clients
|
||||||
|
/// may attach. Dropping the stream cancels the gRPC call cooperatively.
|
||||||
|
/// Optional alarm-reference prefix scoping (`request.alarm_filter_prefix`)
|
||||||
|
/// limits the stream to a sub-tree.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns the `tonic::Status` mapped through [`Error::from`] if the
|
/// Returns the `tonic::Status` mapped through [`Error::from`] if the
|
||||||
/// server rejects the request.
|
/// server rejects the request.
|
||||||
pub async fn query_active_alarms(
|
pub async fn stream_alarms(
|
||||||
&self,
|
&self,
|
||||||
request: QueryActiveAlarmsRequest,
|
request: StreamAlarmsRequest,
|
||||||
) -> Result<ActiveAlarmStream, Error> {
|
) -> Result<AlarmFeedStream, Error> {
|
||||||
let mut client = self.inner.clone();
|
let mut client = self.inner.clone();
|
||||||
let response = client
|
let response = client.stream_alarms(self.stream_request(request)).await?;
|
||||||
.query_active_alarms(self.stream_request(request))
|
|
||||||
.await?;
|
|
||||||
let stream = futures_util::StreamExt::map(response.into_inner(), |result| {
|
let stream = futures_util::StreamExt::map(response.into_inner(), |result| {
|
||||||
result.map_err(Error::from)
|
result.map_err(Error::from)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use futures_core::Stream;
|
use futures_core::Stream;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
|
use mxgateway_client::generated::mxaccess_gateway::v1::alarm_feed_message;
|
||||||
use mxgateway_client::generated::mxaccess_gateway::v1::mx_access_gateway_server::{
|
use mxgateway_client::generated::mxaccess_gateway::v1::mx_access_gateway_server::{
|
||||||
MxAccessGateway, MxAccessGatewayServer,
|
MxAccessGateway, MxAccessGatewayServer,
|
||||||
};
|
};
|
||||||
@@ -16,12 +17,12 @@ use mxgateway_client::generated::mxaccess_gateway::v1::mx_command_reply;
|
|||||||
use mxgateway_client::generated::mxaccess_gateway::v1::mx_value::Kind;
|
use mxgateway_client::generated::mxaccess_gateway::v1::mx_value::Kind;
|
||||||
use mxgateway_client::generated::mxaccess_gateway::v1::{
|
use mxgateway_client::generated::mxaccess_gateway::v1::{
|
||||||
AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot, AddItemReply,
|
AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot, AddItemReply,
|
||||||
BulkReadReply, BulkReadResult, BulkSubscribeReply, BulkWriteReply, BulkWriteResult,
|
AlarmFeedMessage, BulkReadReply, BulkReadResult, BulkSubscribeReply, BulkWriteReply,
|
||||||
CloseSessionReply, CloseSessionRequest, MxCommandKind, MxCommandReply, MxDataType, MxEvent,
|
BulkWriteResult, CloseSessionReply, CloseSessionRequest, MxCommandKind, MxCommandReply,
|
||||||
MxEventFamily, MxStatusCategory, MxStatusProxy, MxStatusSource, MxValue, OpenSessionReply,
|
MxDataType, MxEvent, MxEventFamily, MxStatusCategory, MxStatusProxy, MxStatusSource, MxValue,
|
||||||
OpenSessionRequest, ProtocolStatus, ProtocolStatusCode, QueryActiveAlarmsRequest, SessionState,
|
OpenSessionReply, OpenSessionRequest, ProtocolStatus, ProtocolStatusCode, SessionState,
|
||||||
StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry, WriteSecured2BulkEntry,
|
StreamAlarmsRequest, StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry,
|
||||||
WriteSecuredBulkEntry,
|
WriteSecured2BulkEntry, WriteSecuredBulkEntry,
|
||||||
};
|
};
|
||||||
use mxgateway_client::{
|
use mxgateway_client::{
|
||||||
ApiKey, ClientOptions, CommandError, Error, GatewayClient, MxStatus, MxValue as ClientMxValue,
|
ApiKey, ClientOptions, CommandError, Error, GatewayClient, MxStatus, MxValue as ClientMxValue,
|
||||||
@@ -208,7 +209,6 @@ async fn acknowledge_alarm_returns_reply_with_native_status() {
|
|||||||
|
|
||||||
let reply = client
|
let reply = client
|
||||||
.acknowledge_alarm(AcknowledgeAlarmRequest {
|
.acknowledge_alarm(AcknowledgeAlarmRequest {
|
||||||
session_id: "session-fixture".to_owned(),
|
|
||||||
client_correlation_id: "corr-1".to_owned(),
|
client_correlation_id: "corr-1".to_owned(),
|
||||||
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
||||||
comment: "investigating".to_owned(),
|
comment: "investigating".to_owned(),
|
||||||
@@ -225,7 +225,7 @@ async fn acknowledge_alarm_returns_reply_with_native_status() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn query_active_alarms_streams_snapshot_rows() {
|
async fn stream_alarms_streams_snapshot_then_complete() {
|
||||||
let state = Arc::new(FakeState::default());
|
let state = Arc::new(FakeState::default());
|
||||||
let endpoint = spawn_fake_gateway(state.clone()).await;
|
let endpoint = spawn_fake_gateway(state.clone()).await;
|
||||||
let client = GatewayClient::connect(ClientOptions::new(endpoint))
|
let client = GatewayClient::connect(ClientOptions::new(endpoint))
|
||||||
@@ -233,15 +233,23 @@ async fn query_active_alarms_streams_snapshot_rows() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let mut stream = client
|
let mut stream = client
|
||||||
.query_active_alarms(QueryActiveAlarmsRequest {
|
.stream_alarms(StreamAlarmsRequest::default())
|
||||||
session_id: "session-fixture".to_owned(),
|
|
||||||
..QueryActiveAlarmsRequest::default()
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let first = stream.next().await.unwrap().unwrap();
|
let first = stream.next().await.unwrap().unwrap();
|
||||||
assert_eq!(first.alarm_full_reference, "Tank01.Level.HiHi");
|
match first.payload {
|
||||||
|
Some(alarm_feed_message::Payload::ActiveAlarm(snapshot)) => {
|
||||||
|
assert_eq!(snapshot.alarm_full_reference, "Tank01.Level.HiHi");
|
||||||
|
}
|
||||||
|
other => panic!("expected an active-alarm snapshot, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let second = stream.next().await.unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
second.payload,
|
||||||
|
Some(alarm_feed_message::Payload::SnapshotComplete(true)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -907,7 +915,6 @@ impl MxAccessGateway for FakeGateway {
|
|||||||
_request: Request<AcknowledgeAlarmRequest>,
|
_request: Request<AcknowledgeAlarmRequest>,
|
||||||
) -> Result<Response<AcknowledgeAlarmReply>, Status> {
|
) -> Result<Response<AcknowledgeAlarmReply>, Status> {
|
||||||
Ok(Response::new(AcknowledgeAlarmReply {
|
Ok(Response::new(AcknowledgeAlarmReply {
|
||||||
session_id: "session-fixture".to_owned(),
|
|
||||||
correlation_id: "corr-1".to_owned(),
|
correlation_id: "corr-1".to_owned(),
|
||||||
protocol_status: Some(ok_status("ack ok")),
|
protocol_status: Some(ok_status("ack ok")),
|
||||||
status: Some(MxStatusProxy {
|
status: Some(MxStatusProxy {
|
||||||
@@ -920,18 +927,28 @@ impl MxAccessGateway for FakeGateway {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
type QueryActiveAlarmsStream =
|
type StreamAlarmsStream =
|
||||||
Pin<Box<dyn Stream<Item = Result<ActiveAlarmSnapshot, Status>> + Send + 'static>>;
|
Pin<Box<dyn Stream<Item = Result<AlarmFeedMessage, Status>> + Send + 'static>>;
|
||||||
|
|
||||||
async fn query_active_alarms(
|
async fn stream_alarms(
|
||||||
&self,
|
&self,
|
||||||
_request: Request<QueryActiveAlarmsRequest>,
|
_request: Request<StreamAlarmsRequest>,
|
||||||
) -> Result<Response<Self::QueryActiveAlarmsStream>, Status> {
|
) -> Result<Response<Self::StreamAlarmsStream>, Status> {
|
||||||
let (sender, receiver) = mpsc::channel(4);
|
let (sender, receiver) = mpsc::channel(4);
|
||||||
sender
|
sender
|
||||||
.send(Ok(ActiveAlarmSnapshot {
|
.send(Ok(AlarmFeedMessage {
|
||||||
|
payload: Some(alarm_feed_message::Payload::ActiveAlarm(
|
||||||
|
ActiveAlarmSnapshot {
|
||||||
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
||||||
..ActiveAlarmSnapshot::default()
|
..ActiveAlarmSnapshot::default()
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sender
|
||||||
|
.send(Ok(AlarmFeedMessage {
|
||||||
|
payload: Some(alarm_feed_message::Payload::SnapshotComplete(true)),
|
||||||
}))
|
}))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
+10
-2
@@ -17,7 +17,7 @@ Each module's `findings.md` is the source of truth; this file is generated from
|
|||||||
| [Client.Rust](Client.Rust/findings.md) | Claude Code | 2026-05-20 | `a020350` | Reviewed | 0 | 20 |
|
| [Client.Rust](Client.Rust/findings.md) | Claude Code | 2026-05-20 | `a020350` | Reviewed | 0 | 20 |
|
||||||
| [Contracts](Contracts/findings.md) | Claude Code | 2026-05-20 | `a020350` | Reviewed | 0 | 15 |
|
| [Contracts](Contracts/findings.md) | Claude Code | 2026-05-20 | `a020350` | Reviewed | 0 | 15 |
|
||||||
| [IntegrationTests](IntegrationTests/findings.md) | Claude Code | 2026-05-20 | `a020350` | Reviewed | 0 | 21 |
|
| [IntegrationTests](IntegrationTests/findings.md) | Claude Code | 2026-05-20 | `a020350` | Reviewed | 0 | 21 |
|
||||||
| [Server](Server/findings.md) | Claude Code | 2026-05-20 | `a020350` | Reviewed | 0 | 30 |
|
| [Server](Server/findings.md) | Claude Code | 2026-05-22 | `fa491c7` | Reviewed | 2 | 37 |
|
||||||
| [Tests](Tests/findings.md) | Claude Code | 2026-05-20 | `a020350` | Reviewed | 0 | 24 |
|
| [Tests](Tests/findings.md) | Claude Code | 2026-05-20 | `a020350` | Reviewed | 0 | 24 |
|
||||||
| [Worker](Worker/findings.md) | Claude Code | 2026-05-20 | `a020350` | Reviewed | 0 | 25 |
|
| [Worker](Worker/findings.md) | Claude Code | 2026-05-20 | `a020350` | Reviewed | 0 | 25 |
|
||||||
| [Worker.Tests](Worker.Tests/findings.md) | Claude Code | 2026-05-20 | `a020350` | Reviewed | 0 | 30 |
|
| [Worker.Tests](Worker.Tests/findings.md) | Claude Code | 2026-05-20 | `a020350` | Reviewed | 0 | 30 |
|
||||||
@@ -26,7 +26,10 @@ Each module's `findings.md` is the source of truth; this file is generated from
|
|||||||
|
|
||||||
Findings with status `Open` or `In Progress`, ordered by severity.
|
Findings with status `Open` or `In Progress`, ordered by severity.
|
||||||
|
|
||||||
_No pending findings._
|
| ID | Severity | Category | Location | Description |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Server-031 | Medium | Concurrency & thread safety | `src/MxGateway.Server/Workers/WorkerClient.cs:392-422` (gateway-side heartbeat watchdog); `src/MxGateway.Worker/Ipc/WorkerPipeSession.cs:588-617` (worker-side heartbeat loop); `src/MxGateway.Worker/Ipc/WorkerFrameWriter.cs:14,67-76` (shared `_writeLock`) | Surfaced during the 2026-05-20 cross-language e2e re-run against gateway `b794c46`. The .NET phase succeeded through `open-session`/`register`/`bulk-subscribe`/`bulk-read`/`bulk-unsubscribe`/`stream-events`/`write` but then failed on its t… |
|
||||||
|
| Server-032 | Medium | Error handling & resilience | `src/MxGateway.Server/Workers/WorkerClient.cs:70-77,463-484` (gateway-side `_events` channel); `src/MxGateway.Server/Configuration/EventOptions.cs:8` (default capacity 10,000); `src/MxGateway.Server/Grpc/EventStreamService.cs` (consumer) | Surfaced during the 2026-05-20 cross-language e2e re-run against gateway `b794c46`. The Java phase advised ~55 items (`item-handle 63`) before failing on the next `advise` call with the Server-030 diagnostic `Session ... is not ready. Sess… |
|
||||||
|
|
||||||
## Closed findings
|
## Closed findings
|
||||||
|
|
||||||
@@ -94,6 +97,7 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
|||||||
| Server-016 | Medium | Resolved | Error handling & resilience | `src/MxGateway.Server/Sessions/GatewaySession.cs:790-797`, `src/MxGateway.Server/Sessions/SessionManager.cs:237-258` |
|
| Server-016 | Medium | Resolved | Error handling & resilience | `src/MxGateway.Server/Sessions/GatewaySession.cs:790-797`, `src/MxGateway.Server/Sessions/SessionManager.cs:237-258` |
|
||||||
| Server-021 | Medium | Resolved | Testing coverage | `src/MxGateway.Server/Grpc/MxAccessGatewayService.cs:266-664`, `src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs` |
|
| Server-021 | Medium | Resolved | Testing coverage | `src/MxGateway.Server/Grpc/MxAccessGatewayService.cs:266-664`, `src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs` |
|
||||||
| Server-030 | Medium | Resolved | Error handling & resilience | `src/MxGateway.Server/Sessions/GatewaySession.cs:952-980` |
|
| Server-030 | Medium | Resolved | Error handling & resilience | `src/MxGateway.Server/Sessions/GatewaySession.cs:952-980` |
|
||||||
|
| Server-033 | Medium | Resolved | Error handling & resilience | `src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs:265-323` (`TryRestoreFromDiskAsync`), `:84-99` (`_firstLoad` / `WaitForFirstLoadAsync`); `src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs:141-163` (`WaitForCacheBootstrap`) |
|
||||||
| Tests-003 | Medium | Resolved | Performance & resource management | `src/MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs:170-176`, `src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs:252-258` |
|
| Tests-003 | Medium | Resolved | Performance & resource management | `src/MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs:170-176`, `src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs:252-258` |
|
||||||
| Tests-004 | Medium | Resolved | Testing coverage | `src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs` |
|
| Tests-004 | Medium | Resolved | Testing coverage | `src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs` |
|
||||||
| Tests-005 | Medium | Resolved | Testing coverage | `src/MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs:239-261`, `src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs` |
|
| Tests-005 | Medium | Resolved | Testing coverage | `src/MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs:239-261`, `src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs` |
|
||||||
@@ -235,6 +239,10 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
|||||||
| Server-027 | Low | Resolved | Design-document adherence | `docs/Authorization.md:120-141,176-181` |
|
| Server-027 | Low | Resolved | Design-document adherence | `docs/Authorization.md:120-141,176-181` |
|
||||||
| Server-028 | Low | Resolved | Testing coverage | `src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs:13-20`, `src/MxGateway.Tests/Gateway/Sessions/GatewaySessionTests.cs` |
|
| Server-028 | Low | Resolved | Testing coverage | `src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs:13-20`, `src/MxGateway.Tests/Gateway/Sessions/GatewaySessionTests.cs` |
|
||||||
| Server-029 | Low | Resolved | Documentation & comments | `src/MxGateway.Server/Grpc/MxAccessGatewayService.cs:52-58` |
|
| Server-029 | Low | Resolved | Documentation & comments | `src/MxGateway.Server/Grpc/MxAccessGatewayService.cs:52-58` |
|
||||||
|
| Server-034 | Low | Resolved | Error handling & resilience | `src/MxGateway.Server/Galaxy/GalaxyHierarchySnapshotStore.cs:87-115` (`TryLoadAsync`) |
|
||||||
|
| Server-035 | Low | Resolved | Performance & resource management | `src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs:176` (call site), `:327-352` (`PersistSnapshotAsync`) |
|
||||||
|
| Server-036 | Low | Resolved | Error handling & resilience | `src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs:345-348` (`PersistSnapshotAsync` catch) |
|
||||||
|
| Server-037 | Low | Resolved | Testing coverage | `src/MxGateway.Tests/Galaxy/GalaxyHierarchySnapshotStoreTests.cs`, `src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs` |
|
||||||
| Tests-007 | Low | Resolved | Code organization & conventions | `src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs:682`, `src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs:324`, `src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs:460`, `src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs:233` |
|
| Tests-007 | Low | Resolved | Code organization & conventions | `src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs:682`, `src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs:324`, `src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs:460`, `src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs:233` |
|
||||||
| Tests-008 | Low | Resolved | mxaccessgw conventions | `src/MxGateway.Tests/Gateway/Sessions/WorkerAlarmRpcDispatcherTests.cs:1-9`, `src/MxGateway.Tests/Gateway/Sessions/NotWiredAlarmRpcDispatcherTests.cs:1-3`, `src/MxGateway.Tests/Gateway/Sessions/SessionManagerAlarmAutoSubscribeTests.cs:1` |
|
| Tests-008 | Low | Resolved | mxaccessgw conventions | `src/MxGateway.Tests/Gateway/Sessions/WorkerAlarmRpcDispatcherTests.cs:1-9`, `src/MxGateway.Tests/Gateway/Sessions/NotWiredAlarmRpcDispatcherTests.cs:1-3`, `src/MxGateway.Tests/Gateway/Sessions/SessionManagerAlarmAutoSubscribeTests.cs:1` |
|
||||||
| Tests-009 | Low | Resolved | Documentation & comments | `src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs:36-37,99,365` |
|
| Tests-009 | Low | Resolved | Documentation & comments | `src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs:36-37,99,365` |
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Module | `src/MxGateway.Server` |
|
| Module | `src/MxGateway.Server` |
|
||||||
| Reviewer | Claude Code |
|
| Reviewer | Claude Code |
|
||||||
| Review date | 2026-05-20 |
|
| Review date | 2026-05-22 |
|
||||||
| Commit reviewed | `a020350` |
|
| Commit reviewed | `fa491c7` |
|
||||||
| Status | Reviewed |
|
| Status | Reviewed |
|
||||||
| Open findings | 0 |
|
| Open findings | 2 |
|
||||||
|
|
||||||
## Checklist coverage
|
## Checklist coverage
|
||||||
|
|
||||||
@@ -47,6 +47,28 @@ Re-review pass at `a020350` — the cross-module sweep that resolved Server-015
|
|||||||
| 9 | Testing coverage | Issues found: Server-028 (`GatewayGrpcScopeResolverTests` does not exercise `WatchDeployEventsRequest` or `MxCommandKind.ReadBulk`; no `GatewaySessionTests` case asserts a `MarkFaulted` during in-flight Close). |
|
| 9 | Testing coverage | Issues found: Server-028 (`GatewayGrpcScopeResolverTests` does not exercise `WatchDeployEventsRequest` or `MxCommandKind.ReadBulk`; no `GatewaySessionTests` case asserts a `MarkFaulted` during in-flight Close). |
|
||||||
| 10 | Documentation & comments | Issues found: Server-023 (`NotWiredAlarmRpcDispatcher` class XML doc still says "PR A.6/A.7 — default … shipped while the worker-side AlarmClient event subscription is gated on dev-rig validation"; contradicts the cleanup that Server-014/Server-022 applied to the interface, gateway service, and `WorkerAlarmRpcDispatcher`). Issues found: Server-029 (`OpenSession` capability list advertises `bulk-subscribe-commands` but not the now-shipping bulk-read or bulk-write families — clients that gate on capability strings have no signal that those families exist). |
|
| 10 | Documentation & comments | Issues found: Server-023 (`NotWiredAlarmRpcDispatcher` class XML doc still says "PR A.6/A.7 — default … shipped while the worker-side AlarmClient event subscription is gated on dev-rig validation"; contradicts the cleanup that Server-014/Server-022 applied to the interface, gateway service, and `WorkerAlarmRpcDispatcher`). Issues found: Server-029 (`OpenSession` capability list advertises `bulk-subscribe-commands` but not the now-shipping bulk-read or bulk-write families — clients that gate on capability strings have no signal that those families exist). |
|
||||||
|
|
||||||
|
### 2026-05-22 review (commit fa491c7)
|
||||||
|
|
||||||
|
Re-review pass at `fa491c7`, scoped to the Galaxy hierarchy snapshot-persistence
|
||||||
|
change: the new `GalaxyHierarchySnapshot`, `IGalaxyHierarchySnapshotStore` /
|
||||||
|
`GalaxyHierarchySnapshotStore`, the restore / persist paths added to
|
||||||
|
`GalaxyHierarchyCache`, the two new `GalaxyRepositoryOptions`, and the
|
||||||
|
`docs/GalaxyRepository.md` / `docs/GatewayConfiguration.md` updates. Prior
|
||||||
|
findings (Server-001 through Server-032) are unchanged by this pass.
|
||||||
|
|
||||||
|
| # | Category | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Correctness & logic bugs | No issues found — restore/save sequencing and the shared `BuildEntry` materialization are sound. |
|
||||||
|
| 2 | mxaccessgw conventions | No issues found — file-scoped namespaces, `sealed`, `Async` suffixes, Options pattern, and XML docs all conform; the snapshot persists Galaxy metadata (names/types), not tag values or secrets. |
|
||||||
|
| 3 | Concurrency & thread safety | No issues found — `_restoreAttempted` and `_current` are touched only under `_refreshGate`; `_current` is published via `Volatile.Write`; the store serializes its file I/O on a private `SemaphoreSlim`. |
|
||||||
|
| 4 | Error handling & resilience | Issues found: Server-033 (restore never completes `_firstLoad`, so a cold-start browse waits the full 5s bootstrap budget), Server-034 (`TryLoadAsync` throws on a corrupt file despite the `Try` prefix), Server-036 (a save cancelled at shutdown logs a misleading warning). |
|
||||||
|
| 5 | Security | No issues found — the snapshot holds non-secret Galaxy metadata, is written under `C:\ProgramData\MxGateway` alongside the auth DB, and restored rows flow the same materialization path as live SQL with no injection surface. |
|
||||||
|
| 6 | Performance & resource management | Issues found: Server-035 (the snapshot write is awaited on the refresh critical path under `_refreshGate` with no timeout). |
|
||||||
|
| 7 | Design-document adherence | No issues found — `docs/GalaxyRepository.md` and `docs/GatewayConfiguration.md` were updated in the same commit; `docs/DesignDecisions.md` already defers to `GalaxyRepository.md` as the Galaxy authority. |
|
||||||
|
| 8 | Code organization & conventions | No issues found — the new options live on `GalaxyRepositoryOptions`, the store is a registered singleton, and the on-disk envelope (`PersistedFile`) is a private nested record. |
|
||||||
|
| 9 | Testing coverage | Issues found: Server-037 (no test for the corrupt-snapshot restore path or for `PersistSnapshot = false` at the cache level). |
|
||||||
|
| 10 | Documentation & comments | No issues found — XML docs match behavior; the `GalaxyRepository.md` "On-disk snapshot" section documents the Stale-on-restore lifecycle. |
|
||||||
|
|
||||||
## Findings
|
## Findings
|
||||||
|
|
||||||
### Server-001
|
### Server-001
|
||||||
@@ -504,3 +526,142 @@ Re-review pass at `a020350` — the cross-module sweep that resolved Server-015
|
|||||||
**Recommendation:** Format both states into the exception message — `Session {SessionId} is not ready. Session state is {_state}; worker state is {workerClientState}.` (or `"<no worker>"` when `_workerClient` is null). Document on the method that the two states can diverge under load and that this branch is the fail-fast for that case. Add a regression test that flips `FakeWorkerClient.State` to a non-Ready value (e.g. `Handshaking`) while the session is `Ready` and asserts both pieces of state appear in the thrown `SessionManagerException.Message`. The deeper race investigation (should the gateway briefly wait for worker-Ready before failing? when does `WorkerClient.State` legitimately shift while the session is still `Ready`?) is out of scope for this finding but is worth a follow-up.
|
**Recommendation:** Format both states into the exception message — `Session {SessionId} is not ready. Session state is {_state}; worker state is {workerClientState}.` (or `"<no worker>"` when `_workerClient` is null). Document on the method that the two states can diverge under load and that this branch is the fail-fast for that case. Add a regression test that flips `FakeWorkerClient.State` to a non-Ready value (e.g. `Handshaking`) while the session is `Ready` and asserts both pieces of state appear in the thrown `SessionManagerException.Message`. The deeper race investigation (should the gateway briefly wait for worker-Ready before failing? when does `WorkerClient.State` legitimately shift while the session is still `Ready`?) is out of scope for this finding but is worth a follow-up.
|
||||||
|
|
||||||
**Resolution:** 2026-05-20 — Rewrote `GetReadyWorkerClient` so the `SessionManagerException` message includes both `_state` and `_workerClient.State` (or `"<no worker>"` for the null case): `"Session {SessionId} is not ready. Session state is {_state}; worker state is {workerState}."`. Added XML doc on the method explaining the two-state contract and that this branch is the fail-fast for a state-divergence race. Added regression test `SessionManagerTests.InvokeAsync_WhenWorkerNotReadyButSessionReady_DiagnosticIncludesBothStates` that sets `FakeWorkerClient.State = WorkerClientState.Handshaking` while the session is `Ready` and asserts both `"Session state is Ready"` and `"worker state is Handshaking"` appear in the message; the test also pins `InvokeCount == 0` so the worker isn't called. The deeper race (should `GetReadyWorkerClient` retry briefly when state has just diverged?) remains open for follow-up.
|
**Resolution:** 2026-05-20 — Rewrote `GetReadyWorkerClient` so the `SessionManagerException` message includes both `_state` and `_workerClient.State` (or `"<no worker>"` for the null case): `"Session {SessionId} is not ready. Session state is {_state}; worker state is {workerState}."`. Added XML doc on the method explaining the two-state contract and that this branch is the fail-fast for a state-divergence race. Added regression test `SessionManagerTests.InvokeAsync_WhenWorkerNotReadyButSessionReady_DiagnosticIncludesBothStates` that sets `FakeWorkerClient.State = WorkerClientState.Handshaking` while the session is `Ready` and asserts both `"Session state is Ready"` and `"worker state is Handshaking"` appear in the message; the test also pins `InvokeCount == 0` so the worker isn't called. The deeper race (should `GetReadyWorkerClient` retry briefly when state has just diverged?) remains open for follow-up.
|
||||||
|
|
||||||
|
### Server-031
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Severity | Medium |
|
||||||
|
| Category | Concurrency & thread safety |
|
||||||
|
| Location | `src/MxGateway.Server/Workers/WorkerClient.cs:392-422` (gateway-side heartbeat watchdog); `src/MxGateway.Worker/Ipc/WorkerPipeSession.cs:588-617` (worker-side heartbeat loop); `src/MxGateway.Worker/Ipc/WorkerFrameWriter.cs:14,67-76` (shared `_writeLock`) |
|
||||||
|
| Status | Open |
|
||||||
|
|
||||||
|
**Description:** Surfaced during the 2026-05-20 cross-language e2e re-run against gateway `b794c46`. The .NET phase succeeded through `open-session`/`register`/`bulk-subscribe`/`bulk-read`/`bulk-unsubscribe`/`stream-events`/`write` but then failed on its third `advise` call with the Server-030 diagnostic `Session ... is not ready. Session state is Ready; worker state is Faulted.` The gateway stdout log records the underlying cause: **`Worker client faulted for session session-01a1a07fa59c489983a719821fa46e72: Worker heartbeat expired. Last heartbeat was at 2026-05-20T17:20:39.+00:00.`** — a real 15s+ gap with no `WorkerHeartbeat` envelope arriving from the worker.
|
||||||
|
|
||||||
|
Investigation paths:
|
||||||
|
|
||||||
|
1. **Shared `_writeLock` on the worker side.** `WorkerFrameWriter` serializes every pipe write (heartbeats, command replies, events, faults) through a single `SemaphoreSlim _writeLock` (`WorkerFrameWriter.cs:14`, `:67-76`). `RunEventDrainLoopAsync` (`WorkerPipeSession.cs:336-372`) writes events one at a time inside a `foreach`, each call to `_writer.WriteAsync` re-acquiring `_writeLock`. If the gateway-side read drains slowly and the OS-level named-pipe buffer fills, `_stream.WriteAsync` (`WorkerFrameWriter.cs:70`) blocks. The event-drain loop blocks holding the lock. `RunHeartbeatLoopAsync` (`WorkerPipeSession.cs:611-613`) then can't acquire `_writeLock` to send its 5s heartbeat. Heartbeats stall past the gateway's `HeartbeatGrace` (15s default) and `WorkerClient.HeartbeatLoopAsync` faults the session.
|
||||||
|
|
||||||
|
2. **No prioritization between heartbeats and events.** Even without OS-level back-pressure, a backlog of events in the worker's `MxAccessEventQueue` (drained in batches of `EventDrainBatchSize`) can keep the writer lock held for many milliseconds at a time. Heartbeats can be delayed (though normally not past `HeartbeatGrace` unless something else is wrong).
|
||||||
|
|
||||||
|
3. **Gateway-side heartbeat watchdog ignores in-flight commands.** `WorkerClient.HeartbeatLoopAsync` (`WorkerClient.cs:392-422`) checks only `_state == Ready` and `now - lastHeartbeatAt > HeartbeatGrace`. It does not check whether a command is in flight on the gateway↔worker pipe. The mirror of Worker-017's fix (worker-side watchdog skips `StaHung` while a command is in flight) does not exist on the gateway side.
|
||||||
|
|
||||||
|
The .NET test pattern stresses the issue uniquely because each `dotnet run --project` rebuild between subcommands introduces multi-second client-side gaps; the worker's heartbeat path should still be alive (heartbeats are emitted by `RunHeartbeatLoopAsync` independently of gateway activity), but if the gateway is also blocked draining events from the channel into a non-existent `StreamEvents` consumer, the back-pressure-into-heartbeat chain bites first.
|
||||||
|
|
||||||
|
**Recommendation:** Two changes worth landing together:
|
||||||
|
|
||||||
|
1. **Decouple heartbeat writes from the event/reply lock.** Either (a) give heartbeats their own pipe `Stream` (likely impractical — one pipe per session), (b) introduce a priority queue in front of `WorkerFrameWriter` so heartbeats hop the line, or (c) interleave heartbeat checks inside `RunEventDrainLoopAsync` (e.g., after each event-batch write, post a heartbeat envelope if one is due). Option (c) is the smallest change.
|
||||||
|
|
||||||
|
2. **Mirror Worker-017's "skip-while-command-in-flight" guard on the gateway side.** In `WorkerClient.HeartbeatLoopAsync`, when `_pendingCommands.Count > 0` and the oldest pending command is younger than some ceiling (e.g., 5× `HeartbeatGrace`), skip the fault. The worker may be busy executing a slow STA command and the heartbeat write may be queued behind a long event burst — neither indicates the worker is actually hung.
|
||||||
|
|
||||||
|
Add a regression test that floods the worker's outbound event channel (e.g., via a high-rate STA fixture or a mock event source emitting at > 1000 events/s for several seconds) and asserts the worker is not faulted while the gateway has no `StreamEvents` consumer attached.
|
||||||
|
|
||||||
|
**Resolution:** _(empty until closed; on close, record the fixing commit SHA, the date, and a one-line description of the fix)_
|
||||||
|
|
||||||
|
### Server-032
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Severity | Medium |
|
||||||
|
| Category | Error handling & resilience |
|
||||||
|
| Location | `src/MxGateway.Server/Workers/WorkerClient.cs:70-77,463-484` (gateway-side `_events` channel); `src/MxGateway.Server/Configuration/EventOptions.cs:8` (default capacity 10,000); `src/MxGateway.Server/Grpc/EventStreamService.cs` (consumer) |
|
||||||
|
| Status | Open |
|
||||||
|
|
||||||
|
**Description:** Surfaced during the 2026-05-20 cross-language e2e re-run against gateway `b794c46`. The Java phase advised ~55 items (`item-handle 63`) before failing on the next `advise` call with the Server-030 diagnostic `Session ... is not ready. Session state is Ready; worker state is Faulted.`. The gateway stdout log records: **`Worker client faulted for session session-adfcc808da974808947e87db060c2b03: Worker event channel rejected an event.`** — the gateway-side per-session bounded event channel filled up and `Channel.Writer.TryWrite` returned `false`, triggering the fail-fast path in `EnqueueWorkerEventAsync` (`WorkerClient.cs:467-484`).
|
||||||
|
|
||||||
|
The channel is configured as `Channel.CreateBounded<WorkerEvent>(new BoundedChannelOptions(EventChannelCapacity) { ... FullMode = BoundedChannelFullMode.Wait ... })` (capacity defaults to `EventOptions.QueueCapacity = 10_000`). But `EnqueueWorkerEventAsync` uses **`TryWrite`** (non-blocking), so the configured `Wait` mode is moot — the writer always fails fast when full. This is consistent with `docs/DesignDecisions.md`'s "fail-fast event backpressure" policy (one subscriber per session, no producer-side queuing beyond the channel), but two facts make it sharp in practice:
|
||||||
|
|
||||||
|
1. The e2e flow (and any realistic client) `advise`s many items BEFORE opening a long-running `StreamEvents` consumer. With no consumer, events accumulate at the in-rate (driven by the SCADA tags' change frequency). For `TestMachine_*.TestChangingInt` × ~55 advised items, the rig can fill 10,000 in well under a minute.
|
||||||
|
|
||||||
|
2. The fail-fast threshold is "exactly at capacity." There is no overflow grace window. A momentary lull on the consumer side that lasts long enough for one extra event to arrive after the channel is full results in worker fault and session teardown.
|
||||||
|
|
||||||
|
This is design-as-intended in the v1 sense, but it surfaces a behavioral contract that is **not currently documented**: clients must open `StreamEvents` BEFORE issuing `advise` against high-rate tags, or pace their `advise` calls below the (non-published) accumulation budget. None of the current docs (`gateway.md`, `docs/DesignDecisions.md`, the client READMEs) enforce or surface this requirement, and four of the five client CLIs (`go`, `python`, `rust`, `java`) hit it gracelessly in `scripts/run-client-e2e-tests.ps1`.
|
||||||
|
|
||||||
|
The diagnostic `"Worker event channel rejected an event."` also does not name the actual channel (it says "Worker event channel" but the channel is gateway-owned), the current depth, or the capacity — only that it overflowed. Operators can't tell whether the threshold needs lifting or whether the consumer is genuinely missing.
|
||||||
|
|
||||||
|
**Recommendation:** Three escalating options, pick at least the first and consider one of the others:
|
||||||
|
|
||||||
|
1. **Document the contract.** In `gateway.md` and `docs/DesignDecisions.md`, state explicitly that `advise` produces events into the gateway-side per-session channel and that a `StreamEvents` consumer must be attached to drain it. Add the bound (`MxGateway:Events:QueueCapacity`, default 10,000) and the fault behavior (the worker is faulted; the session ends). Update `clients/*/README.md` to call out the requirement in the "advise" / "subscribe" sections.
|
||||||
|
|
||||||
|
2. **Improve the diagnostic.** Format the channel depth and capacity into the fault message: `"Worker event channel rejected an event after {capacity} unconsumed events accumulated. Attach a StreamEvents consumer or increase MxGateway:Events:QueueCapacity."`
|
||||||
|
|
||||||
|
3. **Add an overflow grace window.** Instead of fail-fast on the first `TryWrite == false`, count overflow events and only fault if N consecutive overflows happen within T ms (or, equivalently, switch to `WriteAsync` with a short timeout). This trades a tiny memory bump for resilience to consumer hiccups. Out of scope if v1 explicitly chose fail-fast for parity reasons — but worth raising for v2.
|
||||||
|
|
||||||
|
Add a regression test that advises N items without an active `StreamEvents` consumer, lets the channel fill, and asserts the produced fault message contains the channel-depth diagnostic (#2) — gated so that #3 is not required.
|
||||||
|
|
||||||
|
**Resolution:** _(empty until closed; on close, record the fixing commit SHA, the date, and a one-line description of the fix)_
|
||||||
|
|
||||||
|
### Server-033
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Severity | Medium |
|
||||||
|
| Category | Error handling & resilience |
|
||||||
|
| Location | `src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs:265-323` (`TryRestoreFromDiskAsync`), `:84-99` (`_firstLoad` / `WaitForFirstLoadAsync`); `src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs:141-163` (`WaitForCacheBootstrap`) |
|
||||||
|
| Status | Resolved |
|
||||||
|
|
||||||
|
**Description:** `TryRestoreFromDiskAsync` populates `_current` with the on-disk snapshot (status `Stale`, `HasData == true`) but never completes the `_firstLoad` `TaskCompletionSource` — only the live-query paths (cheap / heavy / catch) in `RefreshCoreAsync` do. A `DiscoverHierarchy` or `GetLastDeployTime` call that arrives after gateway start but before the first refresh tick finishes sees `cache.Current` as `Empty` (status `Unknown`) when `WaitForCacheBootstrap` runs its initial check, so it falls through to `await WaitForFirstLoadAsync` with a 5-second budget. Restore then completes within milliseconds and makes the data available, but `_firstLoad` stays pending until the live query returns or fails. When the Galaxy database is unreachable — the exact scenario the snapshot feature exists for — the SQL connect attempt outlasts the 5s budget, so the caller waits the full 5 seconds before the budget elapses and the handler falls through to read the (already-restored) data. The result is correct, but the first browse calls after a cold offline start incur a needless ~5s latency, undercutting the feature's purpose.
|
||||||
|
|
||||||
|
**Recommendation:** Call `_firstLoad.TrySetResult()` at the end of `TryRestoreFromDiskAsync` once the restored entry is published — restored data is a valid completed first load. Add a regression test: a cache with a throwing repository plus a populated snapshot store should have `WaitForFirstLoadAsync` complete promptly after `RefreshAsync`, not block on the live query.
|
||||||
|
|
||||||
|
**Resolution:** Resolved in `bdccdbf` (2026-05-22): `TryRestoreFromDiskAsync` calls `_firstLoad.TrySetResult()` immediately after publishing the restored entry, so a restored snapshot satisfies the bootstrap gate without waiting on the live query. New test `GalaxyHierarchyCacheTests.RefreshAsync_RestoredSnapshotCompletesFirstLoadBeforeLiveQueryReturns` blocks the repository's deploy-time query and asserts `WaitForFirstLoadAsync` still completes from the snapshot.
|
||||||
|
|
||||||
|
### Server-034
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Severity | Low |
|
||||||
|
| Category | Error handling & resilience |
|
||||||
|
| Location | `src/MxGateway.Server/Galaxy/GalaxyHierarchySnapshotStore.cs:87-115` (`TryLoadAsync`) |
|
||||||
|
| Status | Resolved |
|
||||||
|
|
||||||
|
**Description:** `TryLoadAsync` carries the `Try` prefix and its XML doc says it returns `null` "when none exists, persistence is disabled, or the on-disk file uses an unrecognized schema version." But a corrupt or partially written JSON file makes `JsonSerializer.DeserializeAsync` throw `JsonException`, and an unreadable file (locked, denied ACL) throws `IOException` / `UnauthorizedAccessException` — none of which the method catches. End-to-end behavior is still safe because the sole caller, `GalaxyHierarchyCache.TryRestoreFromDiskAsync`, wraps the call in a `catch (Exception)`; but the store's own `Try`-prefixed contract is violated, and any future caller would be surprised by the throw.
|
||||||
|
|
||||||
|
**Recommendation:** Catch `JsonException` and `IOException` (the latter covers the `UnauthorizedAccessException` family) inside `TryLoadAsync`, log a warning, and return `null` — consistent with the unrecognized-schema-version branch already present and with the `Try` naming. A corrupt cache file is an expected failure mode for a disk cache.
|
||||||
|
|
||||||
|
**Resolution:** Resolved in `bdccdbf` (2026-05-22): `TryLoadAsync` now has a `catch (Exception) when (exception is JsonException or IOException or UnauthorizedAccessException)` that logs a warning and returns `null`. New test `GalaxyHierarchySnapshotStoreTests.TryLoadAsync_WhenFileIsCorruptJson_ReturnsNull`.
|
||||||
|
|
||||||
|
### Server-035
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Severity | Low |
|
||||||
|
| Category | Performance & resource management |
|
||||||
|
| Location | `src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs:176` (call site), `:327-352` (`PersistSnapshotAsync`) |
|
||||||
|
| Status | Resolved |
|
||||||
|
|
||||||
|
**Description:** After a heavy refresh, `RefreshCoreAsync` `await`s `PersistSnapshotAsync` while still holding `_refreshGate`, and the `SaveAsync` write has no timeout. The only caller of `RefreshAsync` is the sequential `GalaxyHierarchyRefreshService` loop, so a write that hangs — e.g. a `SnapshotCachePath` pointed at an unresponsive network share — blocks the gate and stalls all subsequent cache refreshes until gateway shutdown. Impact is bounded: clients keep being served the last entry (which flips to `Stale` after the 5-minute threshold), so this is a degradation rather than an outage, and the default `C:\ProgramData` path is local disk where a hang is unlikely.
|
||||||
|
|
||||||
|
**Recommendation:** Bound the snapshot write with a timeout — a linked `CancellationTokenSource` cancelling after, say, the SQL `CommandTimeoutSeconds` budget — so a stuck write fails fast and logs rather than pinning the refresh loop. Moving the write off the gate is an alternative but would need its own write-serialization.
|
||||||
|
|
||||||
|
**Resolution:** Resolved in `bdccdbf` (2026-05-22): `SaveAsync` wraps the write in a `CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)` cancelled after `Math.Max(1, CommandTimeoutSeconds)` seconds, so a stuck write fails fast instead of pinning the refresh loop. The timeout-expiry path itself is not unit-tested — exercising it would require a genuinely hanging filesystem.
|
||||||
|
|
||||||
|
### Server-036
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Severity | Low |
|
||||||
|
| Category | Error handling & resilience |
|
||||||
|
| Location | `src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs:345-348` (`PersistSnapshotAsync` catch) |
|
||||||
|
| Status | Resolved |
|
||||||
|
|
||||||
|
**Description:** `PersistSnapshotAsync` passes the refresh `CancellationToken` to `SaveAsync` and catches every exception — including the `OperationCanceledException` thrown when that token is cancelled at gateway shutdown — in its general `catch (Exception)`, logging it as `Warning: "Failed to persist the Galaxy hierarchy snapshot to disk."`. A snapshot write interrupted by a normal shutdown is not a failure, but it surfaces as a misleading warning every time the gateway stops mid-write.
|
||||||
|
|
||||||
|
**Recommendation:** Let a cancellation-driven `OperationCanceledException` pass without the warning — e.g. add `catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }` before the general catch — matching the cancellation handling already used in `RefreshCoreAsync` and `TryRestoreFromDiskAsync`.
|
||||||
|
|
||||||
|
**Resolution:** Resolved in `bdccdbf` (2026-05-22): `PersistSnapshotAsync` has a `catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)` ahead of the general catch, so a save aborted by gateway shutdown is silent while a genuine failure (including a write timeout) still logs. New test `GalaxyHierarchyCacheTests.RefreshAsync_WhenSnapshotSaveCancelledAtShutdown_DoesNotLogPersistFailure`.
|
||||||
|
|
||||||
|
### Server-037
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Severity | Low |
|
||||||
|
| Category | Testing coverage |
|
||||||
|
| Location | `src/MxGateway.Tests/Galaxy/GalaxyHierarchySnapshotStoreTests.cs`, `src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs` |
|
||||||
|
| Status | Resolved |
|
||||||
|
|
||||||
|
**Description:** The new snapshot tests cover the round-trip, missing-file, persistence-disabled, unrecognized-schema, and overwrite cases for the store, and the persist / restore-when-unreachable / promote-on-matching-deploy cases for the cache. Two resilience paths are untested: (1) `GalaxyHierarchyCache.TryRestoreFromDiskAsync`'s `catch` path when the snapshot file is corrupt — the cache must come up `Unavailable` rather than throwing; (2) the cache restore path when `PersistSnapshot = false` (the store yields `null` and the cache stays `Unavailable`). Both are the failure modes most likely to matter operationally.
|
||||||
|
|
||||||
|
**Recommendation:** Add a cache test that writes a corrupt snapshot file and asserts `RefreshAsync` with an unreachable repository leaves the cache `Unavailable` without throwing, and a test that confirms a `PersistSnapshot = false` store neither restores nor persists. If Server-034 is fixed, the corrupt-file test also pins the store's null-return.
|
||||||
|
|
||||||
|
**Resolution:** Resolved in `bdccdbf` (2026-05-22): added `GalaxyHierarchyCacheTests.RefreshAsync_WhenSnapshotFileCorrupt_ComesUpUnavailableWithoutThrowing` and `RefreshAsync_WhenPersistDisabled_DoesNotRestoreFromDisk`, plus the `TryLoadAsync_WhenFileIsCorruptJson_ReturnsNull` store test added for Server-034.
|
||||||
|
|||||||
@@ -1,828 +0,0 @@
|
|||||||
# aaAlarmManagedClient discovery — public surface, 2026-05-01
|
|
||||||
|
|
||||||
Result of running
|
|
||||||
`MxGateway.Worker.Tests.AlarmClientDiscoveryTests.DumpAlarmClientPublicSurface`
|
|
||||||
against the deployed AVEVA assembly:
|
|
||||||
|
|
||||||
- File:
|
|
||||||
`C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll`
|
|
||||||
- Assembly identity: `aaAlarmManagedClient, Version=1.0.7368.41290,
|
|
||||||
Culture=neutral, PublicKeyToken=7ebd82b507d9e10c`
|
|
||||||
|
|
||||||
## Public types
|
|
||||||
|
|
||||||
- `aaAlarmManagedClient.AlarmClient` (class)
|
|
||||||
- `aaAlarmManagedClient.PriorityData` (class)
|
|
||||||
|
|
||||||
That's the entire exported surface — two types, no interfaces, no
|
|
||||||
delegates.
|
|
||||||
|
|
||||||
## `AlarmClient` events
|
|
||||||
|
|
||||||
**None.** The class has no public events at all. The reflection probe's
|
|
||||||
`GetEvents(BindingFlags.Public | Instance | Static)` returned an empty
|
|
||||||
list.
|
|
||||||
|
|
||||||
## `AlarmClient` methods (relevant subset)
|
|
||||||
|
|
||||||
- **Lifecycle:**
|
|
||||||
`RegisterConsumer(int hWnd, string szProductName, string
|
|
||||||
szApplicationName, string szVersion, bool bRetainHiddenAlarms) → int`,
|
|
||||||
`DeregisterConsumer() → int`,
|
|
||||||
`InitializeConsumer(string szApplicationName) → int`,
|
|
||||||
`UninitializeConsumer() → int`,
|
|
||||||
`Dispose()`.
|
|
||||||
- **Subscription:**
|
|
||||||
`Subscribe(string szSubscription, short wFromPri, short wToPri,
|
|
||||||
eQueryType QueryType, eSortFlags SortFlags, eAlarmFilterState
|
|
||||||
FilterMask, eAlarmFilterState FilterSpecification) → int`.
|
|
||||||
- **Change enumeration (pull on poke):**
|
|
||||||
`GetStatistics(out int lPercentQuery, out int lTotalAlarms, out int
|
|
||||||
lActiveAlarms, out int lSuppressedAlarms, out int lSuppressedFilters,
|
|
||||||
out int lNewAlarms, out int lChangesCount, out int[] ChangeCodes,
|
|
||||||
out int[] ChangePos, out int[] hAlarm) → int`.
|
|
||||||
- **Record fetch:**
|
|
||||||
`GetAlarmExtendedRec(int lIndex, out AlarmRecord almRec) → int`,
|
|
||||||
`GetAlarmExtendedRec2(...)`,
|
|
||||||
`GetHighPriAlarm(out AlarmRecord almRec) → int`.
|
|
||||||
- **Selection model** (used by ack-selected-* family):
|
|
||||||
`DeselectAll`, `SelectAlaramEntry(short select, int from, int to)`,
|
|
||||||
`SelectByGUID(Guid)`, `SelectAlarmCount(int from, int to)`.
|
|
||||||
- **Acknowledge:**
|
|
||||||
`AlarmAckByGUID(Guid alarmGuid, string ackComment, string ackOprName,
|
|
||||||
string ackOprNode, string ackOprDomain, string ackOprFullName) → int`
|
|
||||||
is the per-alarm full-fidelity native ack.
|
|
||||||
`AlarmAckSelected(string ackComment, string ackOprName, string
|
|
||||||
ackOprNode, string ackOprDomain, string ackOprFullName) → int`
|
|
||||||
acks whatever the selection model currently has selected.
|
|
||||||
Several `AckSelected*Group/Tag/Priority/All/Visible*Alarms_Ex(...)`
|
|
||||||
variants exist for bulk ack scoped to a group / tag / priority range.
|
|
||||||
- **Suppress / shelve:** `SupressSelected*` and `ShelveSelected*`
|
|
||||||
families plus `DoAlarmShelveAction(...)`. Out of scope for the v1
|
|
||||||
alarm path.
|
|
||||||
- **Snapshot/filter** (`SF*` prefix): `SFSetSortA / SFSetFilterA /
|
|
||||||
SFCreateSnapshot / SFGetListCount / SFDeleteSnapshot / SFRefreshAlarm /
|
|
||||||
SFGetStatistics`. Snapshot-style query API, distinct from the
|
|
||||||
consumer-subscription path. Not currently used.
|
|
||||||
|
|
||||||
## What this means
|
|
||||||
|
|
||||||
The architecture comment on
|
|
||||||
`src/MxGateway.Worker/MxAccess/AlarmClientConsumer.cs` (PR A.5) is
|
|
||||||
**wrong against this deployed assembly**:
|
|
||||||
|
|
||||||
> "The AVEVA alarm-manager surface (`IAlarmMgrDataProvider`) exposes
|
|
||||||
> the events we need as plain .NET events — no Windows message pump
|
|
||||||
> required."
|
|
||||||
|
|
||||||
There is no managed event surface. `AlarmClient.RegisterConsumer`
|
|
||||||
takes an `hWnd` because **WM_APP messaging is the actual notification
|
|
||||||
mechanism**: AVEVA's alarm provider WM_APP-pokes the registered window,
|
|
||||||
and the consumer is expected to call `GetStatistics` on each poke to
|
|
||||||
pull `ChangeCodes` / `ChangePos` / `hAlarm` arrays, then
|
|
||||||
`GetAlarmExtendedRec(pos, …)` per index to fetch each changed record.
|
|
||||||
|
|
||||||
`AlarmClientConsumer.AlarmRecordReceived` has no production callers as
|
|
||||||
a result — `RaiseAlarmRecordReceived` is `internal` for tests and
|
|
||||||
never gets invoked at runtime. Until A.2 lands a WM_APP pump,
|
|
||||||
`MX_EVENT_FAMILY_ON_ALARM_TRANSITION` cannot carry events.
|
|
||||||
|
|
||||||
## Live runtime probe — 2026-05-01
|
|
||||||
|
|
||||||
`MxGateway.Worker.Tests.AlarmClientWmProbeTests.ProbeAlarmClientWmMessages`
|
|
||||||
is a Skip-gated runtime probe that creates a real message-only
|
|
||||||
window, calls `AlarmClient.RegisterConsumer(hWnd, …)` +
|
|
||||||
`Subscribe(@"\Galaxy!", …)`, and pumps for 20s while logging every
|
|
||||||
window message that arrives. Run results below — this turned the
|
|
||||||
"WM_APP pump" design assumption upside down.
|
|
||||||
|
|
||||||
**`RegisterConsumer` and `Subscribe` both returned 0 (success).** The
|
|
||||||
calls are valid against the deployed assembly; no parameter pinning
|
|
||||||
needed.
|
|
||||||
|
|
||||||
**A registered-message-class WM (ID `0xC275` in this OS session)
|
|
||||||
fired every ~1s after `Subscribe` completed.** Constant
|
|
||||||
`wParam = 0x00001100`, constant `lParam = 0x079E46D8` (looks like a
|
|
||||||
stable pointer into AVEVA-internal state) for all 20 hits. The
|
|
||||||
constant payload across hits with no Galaxy alarm being fired
|
|
||||||
suggests this is a **heartbeat/keepalive**, not a per-change
|
|
||||||
notification.
|
|
||||||
|
|
||||||
**Critically: this WM is delivered to AVEVA's own internal window
|
|
||||||
(`hwnd=0x18032E`) — NOT to the consumer's `hWnd` we passed in.** The
|
|
||||||
consumer window's `WndProc` received only the standard creation
|
|
||||||
sequence (`WM_GETMINMAXINFO`, `WM_NCCREATE`, `WM_NCCALCSIZE`,
|
|
||||||
`WM_CREATE`) and the destruction sequence (`WM_NCDESTROY`,
|
|
||||||
`WM_DESTROY`, `WM_NCCALCSIZE`) — nothing in between. AVEVA's
|
|
||||||
notification path runs entirely against AVEVA's internal window;
|
|
||||||
it never forwards to the user-supplied hWnd.
|
|
||||||
|
|
||||||
The message ID itself is dynamic (a `RegisterWindowMessage`
|
|
||||||
allocation in the >= 0xC000 range), so it cannot be hard-coded —
|
|
||||||
each consumer process must call `RegisterWindowMessage` with the
|
|
||||||
correct *string* and use whatever ID the OS returns.
|
|
||||||
|
|
||||||
## What this means for A.2
|
|
||||||
|
|
||||||
The "WM_APP pump on the user hWnd" design — what the original plan
|
|
||||||
banner described and what the previous version of this doc
|
|
||||||
recommended — does not match how AVEVA actually delivers
|
|
||||||
notifications. The hWnd parameter to `RegisterConsumer` does not
|
|
||||||
appear to receive any of AVEVA's alarm traffic; it's likely used
|
|
||||||
only as a registration identity (and perhaps as a parent for modal
|
|
||||||
dialogs).
|
|
||||||
|
|
||||||
Two viable A.2 designs given the probe data:
|
|
||||||
|
|
||||||
1. **Polling.** Just call `GetStatistics` on a timer (e.g. every
|
|
||||||
500ms in the worker's STA) and react to the change set it
|
|
||||||
reports. No window plumbing needed. Trade-off: latency floor =
|
|
||||||
poll period; modest CPU floor because the call is cheap. Matches
|
|
||||||
the heartbeat-style WM 0xC275 semantics — AVEVA itself runs a
|
|
||||||
poll loop internally.
|
|
||||||
2. **Hook AVEVA's internal window.** Discover AVEVA's own window
|
|
||||||
(`hwnd=0x18032E` in the probe), `SetWindowsHookEx` or
|
|
||||||
`SetWindowSubclass` on it, and intercept WM 0xC275 on AVEVA's
|
|
||||||
thread. Higher fidelity, near-zero latency, but invasive,
|
|
||||||
fragile across AVEVA upgrades, and requires running on the same
|
|
||||||
process / thread as the AVEVA window. Probably a non-starter
|
|
||||||
without further AVEVA documentation.
|
|
||||||
|
|
||||||
**Recommendation:** the polling path (option 1) is cheaper to
|
|
||||||
implement, more robust against AVEVA-internal change, and
|
|
||||||
acceptable for a typical alarm cadence. The worker's existing STA
|
|
||||||
already provides a thread-affinitized timer surface. The unanswered
|
|
||||||
question is whether `GetStatistics` can be safely called outside
|
|
||||||
AVEVA's own message-pump thread — confirmable by extending the
|
|
||||||
probe to fire `GetStatistics` on its own thread and check the
|
|
||||||
result.
|
|
||||||
|
|
||||||
## Alarm-provider visibility — third probe run, 2026-05-01
|
|
||||||
|
|
||||||
Extended the probe to call `AlarmClient.GetProviders` after
|
|
||||||
`RegisterConsumer`. Result on this rig:
|
|
||||||
|
|
||||||
```
|
|
||||||
GetProviders -> rc=0 count=0 list=[]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Zero alarm providers visible to the consumer process.** This
|
|
||||||
explains every preceding probe run: no providers means no alarm
|
|
||||||
events, regardless of how many times any value (including a
|
|
||||||
bool with an `$Alarm` extension) flips. `Subscribe(@"\Galaxy!")`
|
|
||||||
returns 0 (success) but matches nothing because the alarm-manager
|
|
||||||
chain that provides the matching feed doesn't expose any provider
|
|
||||||
to this consumer.
|
|
||||||
|
|
||||||
A System Platform script flipping `TestMachine_001.TestAlarm001`
|
|
||||||
every 10s during this probe run produced no observable
|
|
||||||
`GetStatistics` transitions, no `positions[]` / `handles[]`
|
|
||||||
entries, no change in any field — confirms the silence is not
|
|
||||||
about subscription-scope / message-pump but about provider
|
|
||||||
absence.
|
|
||||||
|
|
||||||
### Possible causes
|
|
||||||
|
|
||||||
1. **No `$Alarm` extension on the test bool.** If
|
|
||||||
`TestMachine_001.TestAlarm001` is a regular UDA without a
|
|
||||||
`BoolAlarm` extension wired to it, flipping the value just
|
|
||||||
writes a new value — no alarm fires.
|
|
||||||
2. **Alarm manager service not running.** AVEVA's `aaAlarmMgr`
|
|
||||||
(or the equivalent on this rig's Platform version) needs to
|
|
||||||
be running for providers to register.
|
|
||||||
3. **Process security context.** A consumer running under a
|
|
||||||
normal user account may not see providers that registered
|
|
||||||
under `LocalSystem` / a Platform service identity. The
|
|
||||||
gateway-worker installation runs under a service account
|
|
||||||
that may have access where `dotnet test` doesn't.
|
|
||||||
|
|
||||||
## InitializeConsumer required — fourth probe run, 2026-05-01
|
|
||||||
|
|
||||||
Adding `InitializeConsumer("AlarmProbe.Tests")` before
|
|
||||||
`RegisterConsumer` made `\Galaxy!` appear in `GetProviders`
|
|
||||||
(count=1, status 0 → 100 within 500ms). So #2 and #3 above are
|
|
||||||
NOT the cause — the consumer can see the alarm provider once it
|
|
||||||
calls Initialize. That's a missing API-call ordering, not a
|
|
||||||
permission or service issue.
|
|
||||||
|
|
||||||
```
|
|
||||||
InitializeConsumer -> 0
|
|
||||||
RegisterConsumer -> 0
|
|
||||||
GetProviders [after Register] -> rc=0 count=0 list=[]
|
|
||||||
Subscribe('\Galaxy!') -> 0
|
|
||||||
GetProviders [after Subscribe] -> rc=0 count=1 list=[ 0 \Galaxy!]
|
|
||||||
GetProviders [poll #1] -> rc=0 count=1 list=[100 \Galaxy!]
|
|
||||||
```
|
|
||||||
|
|
||||||
Despite the provider being visible at "100% query complete" for
|
|
||||||
the entire 60s window, `GetStatistics` continued to report
|
|
||||||
`total=0 active=0 codes=[7]` — no alarm transitions reached the
|
|
||||||
consumer even with a System Platform script flipping the test
|
|
||||||
boolean every 10s during the run.
|
|
||||||
|
|
||||||
That isolates the remaining unknown to whether the test bool's
|
|
||||||
alarm extension is actually generating MxAccess alarm-provider
|
|
||||||
events when its value flips. The probe has confirmed every link
|
|
||||||
in the consumer chain works (Initialize → Register → Subscribe →
|
|
||||||
provider visible at 100%) — what's missing is alarm traffic from
|
|
||||||
the producer side. ObjectViewer or another live consumer running
|
|
||||||
alongside the script is the next discriminator: does it visibly
|
|
||||||
see the alarm fire?
|
|
||||||
|
|
||||||
API-ordering finding: `InitializeConsumer` MUST precede
|
|
||||||
`RegisterConsumer` (or at least, must be called before
|
|
||||||
`GetProviders` returns anything). PR A.5's `AlarmClientConsumer`
|
|
||||||
omits `InitializeConsumer` entirely — that's a bug fix to apply
|
|
||||||
even before A.2 lands, since without it the provider chain never
|
|
||||||
becomes visible.
|
|
||||||
|
|
||||||
## Subscribe-parameter sweep — fifth probe run, 2026-05-01
|
|
||||||
|
|
||||||
Even with `InitializeConsumer` + provider visible at status 100,
|
|
||||||
no alarm transitions arrived during a 60s window with the user's
|
|
||||||
script flipping the test bool every 10s. Tried:
|
|
||||||
|
|
||||||
- `qtSummary` and `qtHistory` (the only `eQueryType` values).
|
|
||||||
- Priority 1..999 and 0..32767.
|
|
||||||
- `eAlarmFilterState.asNone` and `asAlarmActiveNow` for both
|
|
||||||
`FilterMask` and `FilterSpecification`.
|
|
||||||
|
|
||||||
`eAlarmFilterState` is single-state-valued (asNone=0,
|
|
||||||
asAlarmActiveNow=1, asAlarmAcked=2, asShelved=3), not flag bits.
|
|
||||||
None of these knobs surfaced any alarm activity.
|
|
||||||
|
|
||||||
User confirmation 2026-05-01: the test bool does have a
|
|
||||||
`BoolAlarm` extension on it; in `aaObjectViewer` the
|
|
||||||
`$Alarm.InAlarm` sub-attribute flips true/false in lockstep with
|
|
||||||
the script's writes. So the alarm extension is **evaluating**
|
|
||||||
its condition, just not visibly producing transitions on the
|
|
||||||
`aaAlarmManagedClient` consumer stream.
|
|
||||||
|
|
||||||
## Multi-channel + multi-subscription probe — sixth run, 2026-05-01
|
|
||||||
|
|
||||||
Extended the probe to try every consumer-side approach in
|
|
||||||
parallel:
|
|
||||||
|
|
||||||
- **Subscription expressions** (sequential): `\Galaxy!`,
|
|
||||||
`\Galaxy!*`, `\\Galaxy!`, `\Galaxy!TestArea`, `\\.\Galaxy!`.
|
|
||||||
All Subscribe calls returned rc=0; the last one
|
|
||||||
(`\\.\Galaxy!`) is reflected in `GetProviders` (count=1).
|
|
||||||
- **Read channels** polled at 500ms cadence: `GetStatistics`,
|
|
||||||
`GetHighPriAlarm`, `SFCreateSnapshot` + `SFGetStatistics`.
|
|
||||||
- **Filter+sort**: priority 0..32767, `qtSummary`,
|
|
||||||
state=`asAlarmActiveNow`, sort=`sfReturnNewestFirst`.
|
|
||||||
- **AlarmRecord init** (worked around `Not a valid Win32
|
|
||||||
FileTime` exception): all DateTime fields pre-set to FILETIME
|
|
||||||
epoch (1601-01-01 UTC) before the call, since
|
|
||||||
`default(DateTime)` is outside FILETIME range and trips the
|
|
||||||
interop marshaler.
|
|
||||||
|
|
||||||
Result of the 60s run with `TestMachine_001.TestAlarm001` being
|
|
||||||
flipped every 10s:
|
|
||||||
|
|
||||||
```
|
|
||||||
Subscribe('\Galaxy!') -> 0
|
|
||||||
Subscribe('\Galaxy!*') -> 0
|
|
||||||
Subscribe('\\Galaxy!') -> 0
|
|
||||||
Subscribe('\Galaxy!TestArea') -> 0
|
|
||||||
Subscribe('\\.\Galaxy!') -> 0
|
|
||||||
GetProviders [after Subscribe-multi] -> count=1 list=[ 0 \\.\Galaxy!]
|
|
||||||
GetStatistics #1: total=0 active=0 changes=1 codes=[7] positions=[] handles=[]
|
|
||||||
GetHighPriAlarm #1: rc=0 { }
|
|
||||||
SF channel #1: SFCreate=0 numAlarms=0 SFStats=0 unackRet=0 unackAlm=0 ackAlm=0 others=0 events=0 idxNewest=-1
|
|
||||||
```
|
|
||||||
|
|
||||||
**No further "(changed)" entries for the entire 60s window.**
|
|
||||||
Every read API returned the same empty result on every poll.
|
|
||||||
|
|
||||||
User confirms the alarm IS firing — `aaObjectViewer` sees
|
|
||||||
`$Alarm.InAlarm` flip in lockstep with the script. Historian
|
|
||||||
records exist (per user — needs verification by querying the
|
|
||||||
historian directly).
|
|
||||||
|
|
||||||
## Conclusion of consumer-side probing
|
|
||||||
|
|
||||||
`aaAlarmManagedClient.AlarmClient` is **not** the receive
|
|
||||||
surface AVEVA's alarm pipeline routes to in this Galaxy
|
|
||||||
configuration. The consumer chain is verified end-to-end:
|
|
||||||
|
|
||||||
- `InitializeConsumer` + `RegisterConsumer` + `Subscribe` all
|
|
||||||
succeed (rc=0).
|
|
||||||
- `GetProviders` finds `\Galaxy!` once Initialize is called.
|
|
||||||
- All read APIs (`GetStatistics`, `GetHighPriAlarm`,
|
|
||||||
`SFCreateSnapshot`/`SFGetStatistics`) return empty even with
|
|
||||||
every documented filter combination.
|
|
||||||
- The consumer's hWnd receives zero AVEVA messages between
|
|
||||||
`WM_CREATE` and `WM_DESTROY`; AVEVA's traffic goes to its own
|
|
||||||
internal hwnd.
|
|
||||||
|
|
||||||
The next investigation directions are not consumer-side:
|
|
||||||
|
|
||||||
1. **Inspect `aaObjectViewer`'s alarm SDK** to see what library
|
|
||||||
it uses to read alarms. If different from
|
|
||||||
`aaAlarmManagedClient`, switch the worker over.
|
|
||||||
2. **Query the historian directly** (`aahEventStorage` /
|
|
||||||
`aahEventSvc`) to confirm alarms are recorded — and use the
|
|
||||||
same path for v2 alarm capture.
|
|
||||||
3. **Inspect AVEVA's alarm-routing config** for this Galaxy in
|
|
||||||
System Platform IDE — area assignments, alarm provider
|
|
||||||
bindings, "publish alarm events to" settings on the platform.
|
|
||||||
|
|
||||||
For A.2 implementation: the `aaAlarmManagedClient` path the
|
|
||||||
gateway-worker is currently architected around may be a
|
|
||||||
dead-end on customer Galaxies configured this way. If the
|
|
||||||
alarms truly only flow through the historian event-storage path,
|
|
||||||
A.2 needs to consume from `aahEventStorage` instead — a
|
|
||||||
fundamental architecture pivot.
|
|
||||||
|
|
||||||
## BREAKTHROUGH — seventh probe run, 2026-05-01
|
|
||||||
|
|
||||||
Two changes finally produced a signal:
|
|
||||||
|
|
||||||
1. **Subscription scope:** `\\<MachineName>\Galaxy!<TopArea>` is the
|
|
||||||
canonical AlarmClient subscription format (per ArchestrA Alarm
|
|
||||||
Client docs at `archestra6.rssing.com/chan-12008125/article13.html`):
|
|
||||||
`\\Node\Provider!Area!Filter`, where Node is the *machine* name,
|
|
||||||
Provider is **literally `Galaxy`**, and Area is a hosted area
|
|
||||||
object. For this rig (`\\DESKTOP-6JL3KKO\Galaxy!DEV`) the DEV
|
|
||||||
area — the platform's primary area — is the right scope. Earlier
|
|
||||||
`\Galaxy!`, `\Galaxy!TestArea`, `\\.\Galaxy!`, etc., all returned
|
|
||||||
rc=0 but matched no traffic — they were not the canonical form.
|
|
||||||
2. **`InitializeConsumer` before `RegisterConsumer`** — already
|
|
||||||
discovered earlier; bug-fix for PR A.5's `AlarmClientConsumer`.
|
|
||||||
|
|
||||||
With both in place, `GetHighPriAlarm` returned a record on every
|
|
||||||
poll for 60s straight (117/117 calls), but threw
|
|
||||||
`ArgumentOutOfRangeException: Not a valid Win32 FileTime` instead
|
|
||||||
of returning successfully — the AlarmRecord struct contains five
|
|
||||||
DateTime fields (`ar_Time`, `ar_OrigTime`, `ar_AckTime`,
|
|
||||||
`ar_RtnTime`, `ar_SubTime`) and AVEVA writes sentinel/invalid
|
|
||||||
FILETIME values for unset ones (e.g., `ar_AckTime` for an
|
|
||||||
unacknowledged alarm). The .NET interop that AVEVA ships
|
|
||||||
(`aaAlarmManagedClient.dll`) auto-converts FILETIME→DateTime and
|
|
||||||
rejects out-of-range values.
|
|
||||||
|
|
||||||
`GetStatistics` continues to report `total=0 active=0` even with
|
|
||||||
GetHighPriAlarm returning records — those two API surfaces have
|
|
||||||
genuinely different views in AVEVA's data model.
|
|
||||||
|
|
||||||
So: **alarms flow through `aaAlarmManagedClient.AlarmClient` once
|
|
||||||
the subscription expression is canonical**. The blocking issue is
|
|
||||||
extracting the payload past the .NET interop's DateTime
|
|
||||||
auto-marshaling.
|
|
||||||
|
|
||||||
## Remaining work to capture alarm payloads
|
|
||||||
|
|
||||||
Define a custom COM interop that uses `long` (FILETIME-as-int64)
|
|
||||||
instead of `DateTime` for the timestamp fields. Approach options:
|
|
||||||
|
|
||||||
1. **Patch the AVEVA-shipped `aaAlarmManagedClient.dll`** — ildasm
|
|
||||||
the assembly, replace `DateTime` with `long` on AlarmRecord's
|
|
||||||
timestamp fields, ilasm back. Brittle across AVEVA upgrades.
|
|
||||||
2. **Write our own `[ComImport]` interface** — declare
|
|
||||||
`IRawAlarmConsumer` ourselves with safe-blittable types,
|
|
||||||
discover the underlying COM IID (via reflection on
|
|
||||||
`AlarmClient`'s `[Guid]` attribute), and `(IRawAlarmConsumer)
|
|
||||||
alarmClient` cast. Cleaner; requires the IID.
|
|
||||||
3. **Use `IDispatch` late binding** — dispatch-Invoke bypasses
|
|
||||||
strong-typed marshaling. Verbose but doesn't need IIDs.
|
|
||||||
|
|
||||||
For PR A.2's worker integration, option 2 is the least
|
|
||||||
disruptive. Once the interop is custom, `AlarmClient.Subscribe` +
|
|
||||||
`GetHighPriAlarm` + `GetAlarmExtendedRec` form a viable
|
|
||||||
polling-style alarm consumer.
|
|
||||||
|
|
||||||
**REVISED 2026-05-01 — option 1 not directly applicable.**
|
|
||||||
Reflection on `aaAlarmManagedClient.AlarmClient` shows it
|
|
||||||
implements only `IDisposable` (no `[ComImport]` interface, no
|
|
||||||
class GUID). It has a single field `CwwAlarmConsumer*
|
|
||||||
m_almUnmanaged` — meaning `AlarmClient` is a **C++/CLI managed
|
|
||||||
wrapper around a native C++ class**, NOT a COM-interop class.
|
|
||||||
The DateTime conversion happens inside the AVEVA wrapper's IL,
|
|
||||||
not at a .NET-to-COM marshaling boundary. There is no separate
|
|
||||||
COM interface IID we can QI to.
|
|
||||||
|
|
||||||
Revised approach options:
|
|
||||||
|
|
||||||
A. **Switch to `wnwrapConsumer.dll`** — a separate standalone
|
|
||||||
COM library AVEVA ships at
|
|
||||||
`C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll`
|
|
||||||
exposing `WNWRAPCONSUMERLib.wwAlarmConsumerClass` with
|
|
||||||
`SetXmlAlarmQuery` / `GetXmlCurrentAlarms`. XML-string output
|
|
||||||
bypasses FILETIME marshaling entirely.
|
|
||||||
B. **Patch `aaAlarmManagedClient.dll` IL** — wrap the unsafe
|
|
||||||
`DateTime.FromFileTime` calls with a safe variant. Direct
|
|
||||||
fix but modifies a vendor binary.
|
|
||||||
C. **Reflect into `m_almUnmanaged` and call native vtable** —
|
|
||||||
get the IntPtr, walk the MSVC C++ vtable, call
|
|
||||||
`__thiscall` methods via `Marshal.GetDelegateForFunctionPointer`.
|
|
||||||
Doable but requires reverse-engineering the C++ class layout.
|
|
||||||
|
|
||||||
Option A is the best fit: real COM-based, self-contained in
|
|
||||||
our code, conventional production-grade approach (the WIN-911
|
|
||||||
consumer pattern referenced in AVEVA support forums uses it).
|
|
||||||
|
|
||||||
The polling-vs-WM_APP-callback question from earlier is now
|
|
||||||
moot: `GetStatistics`'s `positions[]/handles[]` arrays remained
|
|
||||||
empty even when alarms were demonstrably present. The active
|
|
||||||
read API for current alarms is `GetHighPriAlarm`, not
|
|
||||||
`GetStatistics`'s change array.
|
|
||||||
|
|
||||||
### Implications for A.2 implementation
|
|
||||||
|
|
||||||
The A.2 PR's value is unmeasurable until at least one alarm
|
|
||||||
provider is visible. The choice between polling-via-`GetStatistics`
|
|
||||||
and the callback path can only be decided by observing what
|
|
||||||
populates first when a real alarm fires. Without a provider,
|
|
||||||
both paths return the same "nothing happening" answer.
|
|
||||||
|
|
||||||
Until that's resolved, A.2 implementation work is genuinely
|
|
||||||
blocked on a dev-rig configuration issue — not on architectural
|
|
||||||
choice or code structure.
|
|
||||||
|
|
||||||
## GetStatistics polling — second probe run, 2026-05-01
|
|
||||||
|
|
||||||
Extended the probe to call `GetStatistics` every ~2s alongside the
|
|
||||||
WM logger. Key findings:
|
|
||||||
|
|
||||||
- **`GetStatistics` is safely callable from the same thread that
|
|
||||||
did `RegisterConsumer` + `Subscribe`.** Every poll returned rc=0
|
|
||||||
with no exceptions over 9 polls / 20s window.
|
|
||||||
- **The deployed Galaxy currently has zero active alarms.** Every
|
|
||||||
poll reported `total=0 active=0 suppressed=0 newAlarms=0`. The
|
|
||||||
`positions[]` and `handles[]` arrays were empty.
|
|
||||||
- **`changes=1 codes=[7]` was constant across all polls**, matching
|
|
||||||
the constant 1 Hz WM 0xC275 cadence. Code 7 is consistent with a
|
|
||||||
"heartbeat / subscription healthy" sentinel — same semantics as
|
|
||||||
the WM but reported through the pull-side API.
|
|
||||||
- `percent=100` (query-complete percentage) was constant — the
|
|
||||||
subscription is steady-state.
|
|
||||||
|
|
||||||
This confirms the polling design (option 1 in the previous section)
|
|
||||||
is mechanically viable. The remaining open question is whether
|
|
||||||
`GetStatistics` populates `positions[] / handles[]` with real
|
|
||||||
entries when an alarm transition actually fires — proving that
|
|
||||||
requires firing an alarm.
|
|
||||||
|
|
||||||
## Open follow-up probes
|
|
||||||
|
|
||||||
Each can be added to `AlarmClientWmProbeTests` as a separate
|
|
||||||
Skip-gated test:
|
|
||||||
|
|
||||||
1. **Fire a real Galaxy alarm during the pump window.** The cleanest
|
|
||||||
programmatic trigger is an MxAccess write that flips a
|
|
||||||
`$Alarm`-extended boolean to true (alarm in) and back to false
|
|
||||||
(alarm out). Pinning the exact tag reference is pending — needs
|
|
||||||
either a documented test-fixture tag or an interactive selection
|
|
||||||
in System Platform IDE. Once the trigger fires, this resolves
|
|
||||||
whether AVEVA's pulled change set arrives via `GetStatistics`
|
|
||||||
`positions[] / handles[]` (per-change polling works) or only via
|
|
||||||
the AVEVA-internal window (callback path needed).
|
|
||||||
2. **Hook AVEVA's internal window** to log what WMs it actually
|
|
||||||
processes — only relevant if probe 1 shows `GetStatistics` does
|
|
||||||
NOT report per-change activity.
|
|
||||||
3. **Decompile `aaAlarmManagedClient.dll`'s IL** for the
|
|
||||||
`RegisterConsumer` method to find what `RegisterWindowMessage`
|
|
||||||
string is used and whether there's a callback-registration
|
|
||||||
surface on `WNAL_Register` that the managed client wraps. The
|
|
||||||
alarmlst.dll strings (`WNAL_CallBack`, "Invalid callbacks" error)
|
|
||||||
suggest the underlying C API takes callbacks, but the managed
|
|
||||||
wrapper exposes none of them.
|
|
||||||
|
|
||||||
PR A.5's `Subscribe` / `AcknowledgeByGuid` / `SnapshotActiveAlarms`
|
|
||||||
are correct — they're pull-style and don't depend on the
|
|
||||||
notification mechanism.
|
|
||||||
|
|
||||||
## Option A — captured, 2026-05-01
|
|
||||||
|
|
||||||
`wnwrapConsumer.dll` (`C:\Program Files (x86)\Common Files\
|
|
||||||
ArchestrA\wnwrapConsumer.dll`) hosts the standalone COM class
|
|
||||||
`WNWRAPCONSUMERLib.wwAlarmConsumerClass`. Type library imports
|
|
||||||
cleanly via `tlbimp` (output stored under `mxaccessgw/lib/
|
|
||||||
Interop.WNWRAPCONSUMERLib.dll`). The COM class is registered in
|
|
||||||
`HKLM:\SOFTWARE\WOW6432Node\Classes\CLSID\
|
|
||||||
{7AB52E5F-36B2-4A30-AE46-952A746F667C}` with `ThreadingModel=
|
|
||||||
Apartment` — `new wwAlarmConsumerClass()` succeeds via
|
|
||||||
`CoCreateInstance`.
|
|
||||||
|
|
||||||
The probe `MxGateway.Worker.Tests/WnWrapConsumerProbeTests.cs`
|
|
||||||
(Skip-gated, archival) drove the captured run. Lifecycle:
|
|
||||||
|
|
||||||
1. `new wwAlarmConsumerClass()` — instantiated.
|
|
||||||
2. `InitializeConsumer("MxGatewayProbe.WnWrap")` -> 0.
|
|
||||||
3. `RegisterConsumer(hWnd: 0, productName, applicationName,
|
|
||||||
version)` -> 0. **Note:** wnwrap's `RegisterConsumer` is
|
|
||||||
4-arg (no `bRetainHiddenAlarms`); `aaAlarmManagedClient`'s
|
|
||||||
is 5-arg. Different surface.
|
|
||||||
4. `Subscribe(@"\\<machine>\Galaxy!DEV", priLow=1, priHigh=999,
|
|
||||||
qtSummary, sfReturnNewestFirst, asAlarmActiveNow,
|
|
||||||
asAlarmActiveNow)` -> 0. Same canonical scope that worked
|
|
||||||
for `aaAlarmManagedClient`.
|
|
||||||
5. `SetXmlAlarmQuery(...)` was called too but the round-trip
|
|
||||||
`GetXmlAlarmQuery` returned a mangled echo (NODE became
|
|
||||||
`DESKTOP-6JL3KKO\Galaxy!DEV`, PROVIDER became `Galaxy!DEV`,
|
|
||||||
ALARM_STATE shortened to `All`, DISPLAY_MODE truncated to
|
|
||||||
`Sum`). The XML-query path looks broken in this build; rely
|
|
||||||
on `Subscribe` for the filter and skip `SetXmlAlarmQuery` in
|
|
||||||
production. Confirming "Subscribe alone is sufficient" is
|
|
||||||
one follow-up probe (call `Subscribe` and read XML, no
|
|
||||||
`SetXmlAlarmQuery`) — out of scope for the breakthrough run
|
|
||||||
but easy to verify.
|
|
||||||
|
|
||||||
### Captured XML (60 polls over 30s, 500ms cadence)
|
|
||||||
|
|
||||||
`GetXmlCurrentAlarms2(maxAlmCnt: 100, out vartCurrentXmlAlarms)`
|
|
||||||
returned BSTR XML cleanly on every call — 60/60 ok, zero throws.
|
|
||||||
`GetXmlCurrentAlarms` (the v1 method) returned identical content
|
|
||||||
on the same cadence; either method is viable.
|
|
||||||
|
|
||||||
Empty state:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<?xml version="1.0"?><ALARM_RECORDS COUNT="0"></ALARM_RECORDS>
|
|
||||||
```
|
|
||||||
|
|
||||||
With alarm active (`UNACK_ALM`, value=true after the flip
|
|
||||||
script set the bool true):
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<?xml version="1.0"?>
|
|
||||||
<ALARM_RECORDS COUNT="1">
|
|
||||||
<ALARM>
|
|
||||||
<GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>
|
|
||||||
<DATE>2026/5/1</DATE>
|
|
||||||
<TIME>13:26:14.709</TIME>
|
|
||||||
<GMTOFFSET>240</GMTOFFSET>
|
|
||||||
<DSTADJUST>0</DSTADJUST>
|
|
||||||
<PROVIDER_NODE>DESKTOP-6JL3KKO</PROVIDER_NODE>
|
|
||||||
<PROVIDER_NAME>Galaxy</PROVIDER_NAME>
|
|
||||||
<GROUP>TestArea</GROUP>
|
|
||||||
<TAGNAME>TestMachine_001.TestAlarm001</TAGNAME>
|
|
||||||
<TYPE>DSC</TYPE>
|
|
||||||
<VALUE>true</VALUE>
|
|
||||||
<LIMIT>true</LIMIT>
|
|
||||||
<PRIORITY>500</PRIORITY>
|
|
||||||
<STATE>UNACK_ALM</STATE>
|
|
||||||
<OPERATOR_NODE></OPERATOR_NODE>
|
|
||||||
<OPERATOR_NAME></OPERATOR_NAME>
|
|
||||||
<ALARM_COMMENT>Test alarm #1</ALARM_COMMENT>
|
|
||||||
</ALARM>
|
|
||||||
</ALARM_RECORDS>
|
|
||||||
```
|
|
||||||
|
|
||||||
After the script set the bool false (`UNACK_RTN`, value=false):
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<?xml version="1.0"?>
|
|
||||||
<ALARM_RECORDS COUNT="1">
|
|
||||||
<ALARM>
|
|
||||||
<GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>
|
|
||||||
<DATE>2026/5/1</DATE>
|
|
||||||
<TIME>13:26:24.710</TIME>
|
|
||||||
...
|
|
||||||
<VALUE>false</VALUE>
|
|
||||||
<STATE>UNACK_RTN</STATE>
|
|
||||||
...
|
|
||||||
</ALARM>
|
|
||||||
</ALARM_RECORDS>
|
|
||||||
```
|
|
||||||
|
|
||||||
The 10s cadence between transitions matches the System Platform
|
|
||||||
script's flip frequency exactly. **GUID is stable across the
|
|
||||||
in→out cycle** (`BCC4705…` carried through both states), so the
|
|
||||||
XML stream represents the alarm record's lifecycle, not separate
|
|
||||||
event records — this is "current alarms snapshot," not
|
|
||||||
"transition stream." For an OPC UA `AlarmConditionService`
|
|
||||||
adapter this is fine: condition-state changes per-snapshot is
|
|
||||||
the supported model.
|
|
||||||
|
|
||||||
`STATE` enum values observed: `UNACK_RTN` (the alarm has
|
|
||||||
returned to normal but is unacknowledged — i.e., visible in the
|
|
||||||
"current alarms" list because operator hasn't acked it yet) and
|
|
||||||
`UNACK_ALM` (the alarm is currently active and unacknowledged).
|
|
||||||
The other states from `eAlmState` (`ACK_RTN`, `ACK_ALM`) would
|
|
||||||
appear when an ack is performed — `wwAlarmConsumerClass.AlarmAckByGUID`
|
|
||||||
is the method to call.
|
|
||||||
|
|
||||||
### `GetStatistics` AV — unrelated quirk
|
|
||||||
|
|
||||||
Every `GetStatistics` call threw `AccessViolationException` in
|
|
||||||
the probe. Cause: the wnwrap interop signature uses `IntPtr` for
|
|
||||||
the three array out-parameters (`pChangeCode`, `pChangePos`,
|
|
||||||
`phAlarm`); passing `IntPtr.Zero` is wrong — the COM impl is
|
|
||||||
writing into the buffer pointer without null-checking. Pre-
|
|
||||||
allocate three int-arrays and pass pinned pointers (or use
|
|
||||||
`Marshal.AllocCoTaskMem`) to fix. Not required for the
|
|
||||||
production path — the XML methods give us everything we need.
|
|
||||||
|
|
||||||
### Implications for PR A.2 worker integration
|
|
||||||
|
|
||||||
Replacing `aaAlarmManagedClient.AlarmClient` with
|
|
||||||
`WNWRAPCONSUMERLib.wwAlarmConsumerClass` in the worker's
|
|
||||||
alarm-consumer surface unblocks A.2 fully. Outline:
|
|
||||||
|
|
||||||
1. **Reference path:** drop `aaAlarmManagedClient.dll` reference
|
|
||||||
from `MxGateway.Worker.csproj`; add `Interop.WNWRAPCONSUMERLib.dll`
|
|
||||||
reference from `mxaccessgw/lib/`. (Or commit the interop dll
|
|
||||||
in-tree under `lib/` and reference relatively.)
|
|
||||||
2. **`AlarmClientConsumer` → `WnWrapAlarmConsumer`:** rewrite
|
|
||||||
the consumer wrapper to:
|
|
||||||
- `new wwAlarmConsumerClass()` on the worker's STA thread.
|
|
||||||
- `InitializeConsumer(applicationName)` then
|
|
||||||
`RegisterConsumer(hWnd: 0, …)`.
|
|
||||||
- `Subscribe(@"\\<node>\Galaxy!<area>", …)` per configured
|
|
||||||
area. The `<node>` and `<area>` are configurable (default
|
|
||||||
`Environment.MachineName` + the platform's primary area).
|
|
||||||
- Poll `GetXmlCurrentAlarms2(maxAlmCnt, out xml)` on a
|
|
||||||
timer (500ms-1s cadence is comfortable). Parse XML
|
|
||||||
payload; diff against the previous snapshot (keyed by
|
|
||||||
`GUID`); emit `MX_EVENT_FAMILY_ON_ALARM_TRANSITION`
|
|
||||||
events for added/changed/removed records.
|
|
||||||
- `AlarmAckByGUID(VBGUID, comment, oprName, node, domain,
|
|
||||||
fullName)` for client-driven acknowledgements (matches
|
|
||||||
PR A.5's `AlarmAckCommand` payload).
|
|
||||||
- Lifecycle teardown: `DeregisterConsumer` +
|
|
||||||
`UninitializeConsumer` + `Marshal.FinalReleaseComObject`.
|
|
||||||
3. **Conversion layer:** map XML record fields to
|
|
||||||
`MxAlarmConditionRecord` proto:
|
|
||||||
- `GUID` → `condition_id` (canonicalize the no-dashes hex
|
|
||||||
to a UUID string).
|
|
||||||
- `STATE` enum → `inAlarm` + `acked` booleans
|
|
||||||
(`UNACK_ALM` → in_alarm=true, acked=false;
|
|
||||||
`UNACK_RTN` → in_alarm=false, acked=false;
|
|
||||||
`ACK_ALM` → in_alarm=true, acked=true;
|
|
||||||
`ACK_RTN` → in_alarm=false, acked=true).
|
|
||||||
- `DATE + TIME + GMTOFFSET + DSTADJUST` → reassemble UTC
|
|
||||||
timestamp; matches the worker's existing `Timestamp`
|
|
||||||
wire format.
|
|
||||||
- `PRIORITY` → severity (already 1-1000-ish range).
|
|
||||||
- `TAGNAME` → reference; `PROVIDER_NAME` + `GROUP` for
|
|
||||||
scope metadata.
|
|
||||||
4. **PR A.5 fix carry-over:** `InitializeConsumer` MUST be
|
|
||||||
called before `RegisterConsumer` (rediscovered with
|
|
||||||
`aaAlarmManagedClient`, also true here). The existing
|
|
||||||
`AlarmClientConsumer` skips Initialize entirely; the new
|
|
||||||
`WnWrapAlarmConsumer` includes it from day one.
|
|
||||||
5. **Test reuse:** PR A.5's snapshot/ack contract tests can
|
|
||||||
stay — they don't touch the underlying COM API. Add a new
|
|
||||||
integration test against the wnwrap surface (live-AVEVA-only,
|
|
||||||
Skip-gated like the probe).
|
|
||||||
|
|
||||||
### Settled API-ordering and surface knowledge
|
|
||||||
|
|
||||||
- `InitializeConsumer` first, then `RegisterConsumer` — both
|
|
||||||
on `aaAlarmManagedClient.AlarmClient` and
|
|
||||||
`wwAlarmConsumerClass`.
|
|
||||||
- `RegisterConsumer` arity differs:
|
|
||||||
`aaAlarmManagedClient.AlarmClient.RegisterConsumer(hWnd,
|
|
||||||
product, app, version, bRetainHiddenAlarms)` — 5 args;
|
|
||||||
`wwAlarmConsumerClass.RegisterConsumer(hWnd, product, app,
|
|
||||||
version)` — 4 args. The wnwrap class has no
|
|
||||||
`bRetainHiddenAlarms` parameter at all.
|
|
||||||
- Subscription expression format: `\\<machine>\Galaxy!<area>`
|
|
||||||
(literal `Galaxy` provider) for both libraries.
|
|
||||||
- Native ack: `AlarmAckByGUID(VBGUID guid, comment, oprName,
|
|
||||||
node, domain, fullName)` on the v2 surface; ID 5-arg
|
|
||||||
variant on the legacy `IwwAlarmConsumer` interface.
|
|
||||||
|
|
||||||
These findings retire the open follow-up probes from the
|
|
||||||
"polling-vs-pump" debate above — `wwAlarmConsumerClass` plus
|
|
||||||
poll-on-timer is the implementation.
|
|
||||||
|
|
||||||
## Live smoke-test discoveries — 2026-05-01
|
|
||||||
|
|
||||||
The Skip-gated `AlarmsLiveSmokeTests.Alarms_full_pipeline_round_trip`
|
|
||||||
ran the full
|
|
||||||
`WnWrapAlarmConsumer` + `AlarmDispatcher` + `MxAccessAlarmEventSink`
|
|
||||||
pipeline against the dev rig with the flip script running. End-to-end
|
|
||||||
verified: 6 real transitions captured on the 10s cadence, ack-by-name
|
|
||||||
returned rc=0, pipeline stayed healthy through 5 more transitions
|
|
||||||
afterwards. Three production-relevant quirks surfaced and were fixed
|
|
||||||
in the consumer:
|
|
||||||
|
|
||||||
### 1. `SetXmlAlarmQuery` is mandatory for reads despite the mangled echo
|
|
||||||
|
|
||||||
Without `SetXmlAlarmQuery`, the first `GetXmlCurrentAlarms2` call
|
|
||||||
fails with `E_FAIL` (HRESULT `0x80004005`). The discovery doc above
|
|
||||||
flagged the round-trip echo as mangled and recommended skipping the
|
|
||||||
call — that recommendation is **wrong**. The echo *is* mangled (AVEVA
|
|
||||||
parses NODE/PROVIDER/ALARM_STATE/DISPLAY_MODE incorrectly), but the
|
|
||||||
call itself is required as some kind of subscription enabler. Even
|
|
||||||
the Subscribe call setting the actual filter doesn't avoid the need
|
|
||||||
for `SetXmlAlarmQuery`.
|
|
||||||
|
|
||||||
`WnWrapAlarmConsumer.ComposeXmlAlarmQuery(subscription)` decomposes
|
|
||||||
the canonical `\\<machine>\Galaxy!<area>` form into the XML's
|
|
||||||
NODE/PROVIDER/GROUP fields. Mangled or not, the call enables reads.
|
|
||||||
|
|
||||||
### 2. Two consumers required: read-side vs. ack-side
|
|
||||||
|
|
||||||
`SetXmlAlarmQuery` enables reads but **breaks `AlarmAckByName` on
|
|
||||||
the same consumer instance**. With SetXml applied, AlarmAckByName
|
|
||||||
returns -55 even with valid name+provider+group+operator. Without
|
|
||||||
SetXml, AlarmAckByName succeeds with rc=0.
|
|
||||||
|
|
||||||
The production consumer therefore provisions **two** wnwrap COM
|
|
||||||
instances:
|
|
||||||
- Primary consumer (`client`): runs full lifecycle including
|
|
||||||
`SetXmlAlarmQuery` for `GetXmlCurrentAlarms2` polls.
|
|
||||||
- Ack-only consumer (`ackClient`): runs Initialize → Register →
|
|
||||||
Subscribe via the v1-prefixed methods, **no SetXmlAlarmQuery**.
|
|
||||||
All `AcknowledgeByName` calls dispatch through this instance.
|
|
||||||
|
|
||||||
Both consumers subscribe to the same expression. Disposal cleans up
|
|
||||||
both via a shared `ReleaseConsumerCom` helper.
|
|
||||||
|
|
||||||
### 3. `AlarmAckByName` v2 8-arg vs. v1 6-arg
|
|
||||||
|
|
||||||
`wwAlarmConsumerClass` exposes two `AlarmAckByName` overloads:
|
|
||||||
- `IwwAlarmConsumer2` v2: 8 args (`name, provider, group, comment,
|
|
||||||
oprName, node, domainName, oprFullName`).
|
|
||||||
- `IwwAlarmConsumer` v1: 6 args (no domain, no full-name).
|
|
||||||
|
|
||||||
The v2 8-arg method returns -55 on this AVEVA build regardless of
|
|
||||||
operator-identity inputs — looks like a stub. The v1 6-arg method
|
|
||||||
works. Production `WnWrapAlarmConsumer.AcknowledgeByName` calls the
|
|
||||||
6-arg overload and discards the proto's `domain` + `full_name` fields.
|
|
||||||
The proto contract keeps the 8 fields for forward compatibility if
|
|
||||||
AVEVA fixes the v2 method later.
|
|
||||||
|
|
||||||
### 4. `AlarmAckByGUID` is not implemented
|
|
||||||
|
|
||||||
The v2 `AlarmAckByGUID(VBGUID, …)` throws `NotImplementedException`
|
|
||||||
(COM `E_NOTIMPL`) on `wwAlarmConsumerClass` against this AVEVA
|
|
||||||
build. The reference→GUID lookup that we initially planned to wire
|
|
||||||
through `AlarmAckByGUID` is therefore not viable on wnwrap; all acks
|
|
||||||
must go through `AlarmAckByName`.
|
|
||||||
|
|
||||||
The proto `AcknowledgeAlarmCommand` (GUID-based) and the worker's
|
|
||||||
`MxAccessCommandExecutor.ExecuteAcknowledgeAlarm` switch arm remain
|
|
||||||
in the codebase for the forward-compat shape, but the gateway-side
|
|
||||||
`WorkerAlarmRpcDispatcher.AcknowledgeAsync` now always routes through
|
|
||||||
`AcknowledgeAlarmByName` when the public RPC supplies a recognizable
|
|
||||||
`Provider!Group.Tag` reference.
|
|
||||||
|
|
||||||
**Command/reply payload reuse.** `MxCommand.payload` has a dedicated
|
|
||||||
`acknowledge_alarm_by_name_command` field, but `MxCommandReply.payload`
|
|
||||||
intentionally has **no** by-name-specific case. The by-name ack carries
|
|
||||||
no outcome detail beyond the native return code, so the worker's
|
|
||||||
`ExecuteAcknowledgeAlarmByName` sets the same `acknowledge_alarm`
|
|
||||||
(`AcknowledgeAlarmReplyPayload`) reply case used by the GUID arm, with
|
|
||||||
`native_status` = the `AlarmAckByName` return code (also echoed into the
|
|
||||||
top-level `MxCommandReply.hresult`). Reply consumers must dispatch on
|
|
||||||
`MxCommandReply.kind` (`MX_COMMAND_KIND_ACKNOWLEDGE_ALARM` vs.
|
|
||||||
`MX_COMMAND_KIND_ACKNOWLEDGE_ALARM_BY_NAME`), not on the payload oneof
|
|
||||||
case, to distinguish the two acks. `WorkerAlarmRpcDispatcher` reads only
|
|
||||||
the top-level `hresult`/`protocol_status`, so it handles both arms
|
|
||||||
without unpacking the payload.
|
|
||||||
|
|
||||||
**Worker `native_status` → public `AcknowledgeAlarmReply` mapping.** The
|
|
||||||
worker carries the ack outcome as a single `int32`
|
|
||||||
(`AcknowledgeAlarmReplyPayload.native_status`, the `AlarmAckByName` /
|
|
||||||
`AlarmAckByGUID` return code; `0` = success), also mirrored into the
|
|
||||||
worker `MxCommandReply.hresult`. The public `AcknowledgeAlarmReply` has
|
|
||||||
two outcome-shaped fields, but only one is populated:
|
|
||||||
|
|
||||||
- `AcknowledgeAlarmReply.hresult` — `WorkerAlarmRpcDispatcher` copies the
|
|
||||||
worker's `MxCommandReply.hresult` (the native return code) into this
|
|
||||||
field. **This is the authoritative ack-outcome field**; `0` means the
|
|
||||||
ack succeeded. It is absent only when the worker reply omitted the
|
|
||||||
value, which is a protocol violation surfaced in `protocol_status`.
|
|
||||||
- `AcknowledgeAlarmReply.status` (`MxStatusProxy`) — the worker by-name /
|
|
||||||
by-GUID ack path produces only the `int32` return code, never a
|
|
||||||
populated `MXSTATUS_PROXY` struct, so `WorkerAlarmRpcDispatcher` leaves
|
|
||||||
this field **unset on every reply**. It is reserved for a future
|
|
||||||
structured view of the ack outcome. Clients must not depend on it.
|
|
||||||
|
|
||||||
Client authors should therefore branch on `protocol_status` first (for
|
|
||||||
transport/session-level failures) and then on `hresult` (`0` = ack
|
|
||||||
accepted by MXAccess) — never on `status`.
|
|
||||||
|
|
||||||
### 5. STA / threading — production fix needed
|
|
||||||
|
|
||||||
The wnwrap COM is `ThreadingModel=Apartment`. The consumer's
|
|
||||||
internal `Timer` fires on threadpool threads and would block forever
|
|
||||||
on cross-apartment marshaling unless the host STA pumps Win32
|
|
||||||
messages. The smoke test sidesteps this by setting
|
|
||||||
`pollIntervalMilliseconds=0` (Timer disabled) and driving `PollOnce`
|
|
||||||
manually from the test's STA. Production hosting will route polls
|
|
||||||
through the worker's `StaRuntime` in a follow-up — the consumer's
|
|
||||||
`PollOnce` is `public` and idempotent so the wire-up is mechanical.
|
|
||||||
|
|
||||||
### Capture summary
|
|
||||||
|
|
||||||
```
|
|
||||||
Transition: kind=Clear ref='Galaxy!TestArea.TestMachine_001.TestAlarm001' …
|
|
||||||
Transition: kind=Raise ref='Galaxy!TestArea.TestMachine_001.TestAlarm001' …
|
|
||||||
SnapshotActiveAlarms count=1
|
|
||||||
active: ref='Galaxy!TestArea.TestMachine_001.TestAlarm001' state=Active
|
|
||||||
AcknowledgeByName(real identity) -> rc=0
|
|
||||||
Post-ack transition: kind=Clear …
|
|
||||||
+1: kind=Raise … (10s after ack)
|
|
||||||
+2: kind=Clear … (20s)
|
|
||||||
+3: kind=Raise … (30s)
|
|
||||||
+4: kind=Clear … (40s)
|
|
||||||
```
|
|
||||||
|
|
||||||
10s cadence held throughout; full proto fields populated correctly;
|
|
||||||
ack registered server-side without errors.
|
|
||||||
@@ -103,7 +103,7 @@ public string ResolveRequiredScope(object request)
|
|||||||
StreamEventsRequest => GatewayScopes.EventsRead,
|
StreamEventsRequest => GatewayScopes.EventsRead,
|
||||||
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
|
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
|
||||||
AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite,
|
AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite,
|
||||||
QueryActiveAlarmsRequest => GatewayScopes.EventsRead,
|
StreamAlarmsRequest => GatewayScopes.EventsRead,
|
||||||
TestConnectionRequest or
|
TestConnectionRequest or
|
||||||
GetLastDeployTimeRequest or
|
GetLastDeployTimeRequest or
|
||||||
DiscoverHierarchyRequest or
|
DiscoverHierarchyRequest or
|
||||||
@@ -113,7 +113,7 @@ public string ResolveRequiredScope(object request)
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and `QueryActiveAlarms` shares the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so it carries `events:read`.
|
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and `StreamAlarms` shares the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so it carries `events:read`. Both alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
|
||||||
|
|
||||||
`MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope:
|
`MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope:
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ blocking constraint; secured values and raw credentials are never logged.
|
|||||||
|----------|-------|--------------|
|
|----------|-------|--------------|
|
||||||
| `SessionOpen` | `session:open` | `OpenSessionRequest` |
|
| `SessionOpen` | `session:open` | `OpenSessionRequest` |
|
||||||
| `SessionClose` | `session:close` | `CloseSessionRequest` |
|
| `SessionClose` | `session:close` | `CloseSessionRequest` |
|
||||||
| `EventsRead` | `events:read` | `StreamEventsRequest`, `QueryActiveAlarmsRequest`, `MxCommandKind.DrainEvents` |
|
| `EventsRead` | `events:read` | `StreamEventsRequest`, `StreamAlarmsRequest`, `MxCommandKind.DrainEvents` |
|
||||||
| `InvokeRead` | `invoke:read` | `MxCommandRequest` for read-style command kinds (`Register`, `AddItem`, `Advise`, `ReadBulk`, and any kind not otherwise mapped) |
|
| `InvokeRead` | `invoke:read` | `MxCommandRequest` for read-style command kinds (`Register`, `AddItem`, `Advise`, `ReadBulk`, and any kind not otherwise mapped) |
|
||||||
| `InvokeWrite` | `invoke:write` | `AcknowledgeAlarmRequest`, `MxCommandKind.Write`, `MxCommandKind.Write2`, `MxCommandKind.WriteBulk`, `MxCommandKind.Write2Bulk` |
|
| `InvokeWrite` | `invoke:write` | `AcknowledgeAlarmRequest`, `MxCommandKind.Write`, `MxCommandKind.Write2`, `MxCommandKind.WriteBulk`, `MxCommandKind.Write2Bulk` |
|
||||||
| `InvokeSecure` | `invoke:secure` | `MxCommandKind.WriteSecured`, `MxCommandKind.WriteSecured2`, `MxCommandKind.WriteSecuredBulk`, `MxCommandKind.WriteSecured2Bulk`, `MxCommandKind.AuthenticateUser` |
|
| `InvokeSecure` | `invoke:secure` | `MxCommandKind.WriteSecured`, `MxCommandKind.WriteSecured2`, `MxCommandKind.WriteSecuredBulk`, `MxCommandKind.WriteSecured2Bulk`, `MxCommandKind.AuthenticateUser` |
|
||||||
|
|||||||
@@ -82,6 +82,18 @@ fan-out may be added later with explicit backpressure semantics.
|
|||||||
Rationale: one subscriber preserves simple event ordering and failure behavior
|
Rationale: one subscriber preserves simple event ordering and failure behavior
|
||||||
while parity is being proven.
|
while parity is being proven.
|
||||||
|
|
||||||
|
### Alarms — superseded for the alarm subsystem
|
||||||
|
|
||||||
|
The single-subscriber rule above no longer applies to alarms. The gateway runs
|
||||||
|
an always-on central alarm monitor (`GatewayAlarmMonitor`) that owns one
|
||||||
|
gateway-managed worker session, caches the active-alarm set, and fans it out to
|
||||||
|
any number of clients through the session-less `StreamAlarms` RPC. Per-session
|
||||||
|
alarm auto-subscribe is removed; `AcknowledgeAlarm` is session-less and routes
|
||||||
|
through the monitor. Data-side `StreamEvents` remains one subscriber per
|
||||||
|
session. Rationale: alarm state is gateway-wide, not session-scoped — every
|
||||||
|
client wants the same current set plus updates, and forcing each to own a
|
||||||
|
worker would multiply AVEVA polling load for no benefit.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Decision: API key authentication for the public gateway.
|
Decision: API key authentication for the public gateway.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
The gateway exposes a read-only browse surface over the AVEVA System Platform
|
The gateway exposes a read-only browse surface over the AVEVA System Platform
|
||||||
Galaxy Repository (the SQL Server database named `ZB`). Clients use it to
|
Galaxy Repository (the SQL Server database named `ZB`). Clients use it to
|
||||||
enumerate the deployed object hierarchy and each object's dynamic attributes
|
enumerate the deployed object hierarchy and each object's attributes
|
||||||
before subscribing to runtime values via the existing `MxAccessGateway` RPCs.
|
before subscribing to runtime values via the existing `MxAccessGateway` RPCs.
|
||||||
|
|
||||||
This is a metadata layer: it never reads or writes runtime tag values, never
|
This is a metadata layer: it never reads or writes runtime tag values, never
|
||||||
@@ -19,8 +19,10 @@ ArchestrA IDE renders the deployment tree. Surfacing that data over gRPC lets
|
|||||||
remote clients build a navigable address space without any coupling to the
|
remote clients build a navigable address space without any coupling to the
|
||||||
COM layer or the host platform.
|
COM layer or the host platform.
|
||||||
|
|
||||||
The query bodies are kept byte-for-byte identical to the equivalent OPC UA
|
`HierarchySql` is the object-hierarchy query originally ported from the
|
||||||
server in the OtOpcUa project so the two consumers see the same row sets.
|
equivalent OPC UA server in the OtOpcUa project. `AttributesSql` has since
|
||||||
|
diverged from OtOpcUa — see [Built-in vs configured attributes](#built-in-vs-configured-attributes)
|
||||||
|
— and is no longer kept in sync with it.
|
||||||
|
|
||||||
## RPC Surface
|
## RPC Surface
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ The service is defined in
|
|||||||
|-----|---------|
|
|-----|---------|
|
||||||
| `TestConnection` | Connectivity probe. Returns `{ ok: bool }` after a `SELECT 1`. Does not throw on SQL failure — returns `ok = false`. Always hits SQL directly so it remains a true health check. |
|
| `TestConnection` | Connectivity probe. Returns `{ ok: bool }` after a `SELECT 1`. Does not throw on SQL failure — returns `ok = false`. Always hits SQL directly so it remains a true health check. |
|
||||||
| `GetLastDeployTime` | Returns the cached `galaxy.time_of_last_deploy`. Served from the shared hierarchy cache; refreshed in the background. |
|
| `GetLastDeployTime` | Returns the cached `galaxy.time_of_last_deploy`. Served from the shared hierarchy cache; refreshed in the background. |
|
||||||
| `DiscoverHierarchy` | Returns one page of the deployed hierarchy plus each returned object's dynamic attributes. **Served from cache** — see [Hierarchy Cache](#hierarchy-cache). |
|
| `DiscoverHierarchy` | Returns one page of the deployed hierarchy plus each returned object's attributes (configured and built-in — see [Built-in vs configured attributes](#built-in-vs-configured-attributes)). **Served from cache** — see [Hierarchy Cache](#hierarchy-cache). |
|
||||||
| `WatchDeployEvents` | **Server-streaming.** The server emits the current state immediately on subscribe (so clients can bootstrap without waiting), then emits one event per detected deploy change. See [Deploy Notifications](#deploy-notifications). |
|
| `WatchDeployEvents` | **Server-streaming.** The server emits the current state immediately on subscribe (so clients can bootstrap without waiting), then emits one event per detected deploy change. See [Deploy Notifications](#deploy-notifications). |
|
||||||
|
|
||||||
`DiscoverHierarchy` is a paged unary RPC. The raw request accepts `page_size`
|
`DiscoverHierarchy` is a paged unary RPC. The raw request accepts `page_size`
|
||||||
@@ -87,6 +89,36 @@ load to complete before returning. If the first load fails or times out,
|
|||||||
the client gets `Unavailable` with a short reason. Once any load completes
|
the client gets `Unavailable` with a short reason. Once any load completes
|
||||||
(success or failure), this wait is skipped on subsequent calls.
|
(success or failure), this wait is skipped on subsequent calls.
|
||||||
|
|
||||||
|
### On-disk snapshot
|
||||||
|
|
||||||
|
The gateway may lose connectivity to the Galaxy database — and the database is
|
||||||
|
often unreachable right when the gateway itself restarts. To keep browse
|
||||||
|
working across that gap, the cache persists its dataset to disk:
|
||||||
|
|
||||||
|
- After every successful **heavy** refresh (a deploy change), the raw
|
||||||
|
hierarchy and attribute rowsets are written to
|
||||||
|
`MxGateway:Galaxy:SnapshotCachePath`
|
||||||
|
(default `C:\ProgramData\MxGateway\galaxy-snapshot.json`). The write is
|
||||||
|
atomic — a temp file plus rename — so a crash mid-write cannot corrupt the
|
||||||
|
snapshot. Cheap no-change ticks write nothing; the file is already current.
|
||||||
|
- On the **first** refresh after startup, before any SQL runs, the cache
|
||||||
|
reloads that file. The restored data is served with `Stale` status —
|
||||||
|
it is last-known data, not live — so clients can browse immediately even
|
||||||
|
when the Galaxy database is unreachable.
|
||||||
|
- The first live query then reconciles: if it observes the **same**
|
||||||
|
`time_of_last_deploy` the snapshot was saved at, the entry is promoted to
|
||||||
|
`Healthy` with no heavy re-query (the snapshot is provably current); if it
|
||||||
|
observes a newer deploy, the heavy queries run and replace the snapshot; if
|
||||||
|
the database is still unreachable, the entry stays `Stale`.
|
||||||
|
|
||||||
|
`is_alarm` / `is_historized` filters, paging, and the dashboard summary all
|
||||||
|
work against a restored snapshot exactly as against a live pull — the restore
|
||||||
|
path runs the same materialization. Persistence is disabled by setting
|
||||||
|
`MxGateway:Galaxy:PersistSnapshot` to `false`; the snapshot file is then
|
||||||
|
neither written nor read, and a cold start with an unreachable database comes
|
||||||
|
up `Unavailable` as before. The on-disk file is a cache, not a system of
|
||||||
|
record: deleting it only forces the next cold start to wait for live SQL.
|
||||||
|
|
||||||
## Deploy Notifications
|
## Deploy Notifications
|
||||||
|
|
||||||
`WatchDeployEvents` is a server-streaming RPC backed by
|
`WatchDeployEvents` is a server-streaming RPC backed by
|
||||||
@@ -176,6 +208,43 @@ message DiscoverHierarchyReply {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Built-in vs configured attributes
|
||||||
|
|
||||||
|
Each `GalaxyObject` carries two kinds of attribute, both surfaced the same way
|
||||||
|
in the `attributes` list:
|
||||||
|
|
||||||
|
- **Configured (dynamic) attributes** — attributes added in the ArchestrA IDE
|
||||||
|
attribute editor. Stored in the Galaxy `dynamic_attribute` table.
|
||||||
|
- **Built-in attributes** — attributes every object inherits from its
|
||||||
|
primitives: the object framework, the engine/platform primitives, and the
|
||||||
|
per-attribute extensions (Alarm, History, Boolean, …). Stored in
|
||||||
|
`attribute_definition` and reached through `primitive_instance`.
|
||||||
|
|
||||||
|
Built-in attributes are why an `AppEngine` or `WinPlatform` object reports its
|
||||||
|
`Engine.*` and `Alarm*` attributes, and why an alarmed attribute such as
|
||||||
|
`TestAlarm001` reports its extension leaves `TestAlarm001.Acked`,
|
||||||
|
`TestAlarm001.AckMsg`, `TestAlarm001.ActiveAlarmState`, and so on. An earlier
|
||||||
|
version of the browse query returned only configured attributes, so those
|
||||||
|
objects came back empty or partial; including built-ins makes the browse
|
||||||
|
surface match what System Platform's own Object Viewer shows. Expect roughly
|
||||||
|
seven times as many attributes as configured-only — the dashboard attribute
|
||||||
|
count reflects this.
|
||||||
|
|
||||||
|
Two rules govern the built-in rows:
|
||||||
|
|
||||||
|
- **No category filter.** `attribute_definition` uses a different
|
||||||
|
`mx_attribute_category` numbering than `dynamic_attribute`, so only the
|
||||||
|
`_`-prefixed-name and `.Description` exclusions apply to built-ins. (The
|
||||||
|
configured-attribute category allow-list is unchanged.)
|
||||||
|
- **`is_historized` / `is_alarm` are always `false` for built-in rows.** Those
|
||||||
|
flags identify a configured attribute that *anchors* a history or alarm
|
||||||
|
extension (e.g. `TestAlarm001`), not the extension's machinery leaves
|
||||||
|
(`TestAlarm001.Acked`). `alarm_bearing_only` and `historized_only` therefore
|
||||||
|
still select the anchor attributes, not their built-in children.
|
||||||
|
|
||||||
|
When a configured attribute and a built-in attribute resolve to the same
|
||||||
|
reference, the configured attribute wins.
|
||||||
|
|
||||||
### Contained name vs tag name
|
### Contained name vs tag name
|
||||||
|
|
||||||
Galaxy objects carry two names. `tag_name` is globally unique and is what
|
Galaxy objects carry two names. `tag_name` is globally unique and is what
|
||||||
@@ -219,10 +288,11 @@ GalaxyHierarchyRefreshService (BackgroundService)
|
|||||||
Component breakdown:
|
Component breakdown:
|
||||||
|
|
||||||
- `GalaxyRepository` (`src/MxGateway.Server/Galaxy/GalaxyRepository.cs`) holds
|
- `GalaxyRepository` (`src/MxGateway.Server/Galaxy/GalaxyRepository.cs`) holds
|
||||||
the SQL. Its constants `HierarchySql` and `AttributesSql` are copied verbatim
|
the SQL. Both `HierarchySql` and `AttributesSql` walk template-derivation and
|
||||||
from the OtOpcUa project; do not edit them in isolation here. The two
|
package-derivation chains via recursive CTEs and pick the most-derived
|
||||||
queries walk template-derivation and package-derivation chains via
|
override per object. `HierarchySql` still matches the OtOpcUa original;
|
||||||
recursive CTEs and pick the most-derived attribute override per object.
|
`AttributesSql` does not — it additionally enumerates built-in primitive
|
||||||
|
attributes (see [Built-in vs configured attributes](#built-in-vs-configured-attributes)).
|
||||||
- `GalaxyHierarchyCache`
|
- `GalaxyHierarchyCache`
|
||||||
(`src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs`) holds the most
|
(`src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs`) holds the most
|
||||||
recent immutable `GalaxyHierarchyCacheEntry` (materialized objects +
|
recent immutable `GalaxyHierarchyCacheEntry` (materialized objects +
|
||||||
@@ -251,6 +321,8 @@ Bound to `MxGateway:Galaxy` via `GalaxyRepositoryOptions`.
|
|||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `MxGateway:Galaxy:ConnectionString` | `Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;` | SQL Server connection string for the Galaxy Repository. Integrated Security against `localhost` is the dev default; production deployments should override this through the standard double-underscore environment variable form, e.g. `MxGateway__Galaxy__ConnectionString`. |
|
| `MxGateway:Galaxy:ConnectionString` | `Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;` | SQL Server connection string for the Galaxy Repository. Integrated Security against `localhost` is the dev default; production deployments should override this through the standard double-underscore environment variable form, e.g. `MxGateway__Galaxy__ConnectionString`. |
|
||||||
| `MxGateway:Galaxy:CommandTimeoutSeconds` | `60` | Per-command SQL timeout. Applies to all three RPCs. |
|
| `MxGateway:Galaxy:CommandTimeoutSeconds` | `60` | Per-command SQL timeout. Applies to all three RPCs. |
|
||||||
|
| `MxGateway:Galaxy:PersistSnapshot` | `true` | Persists each successful browse dataset to disk and reloads it at startup. See [On-disk snapshot](#on-disk-snapshot). |
|
||||||
|
| `MxGateway:Galaxy:SnapshotCachePath` | `C:\ProgramData\MxGateway\galaxy-snapshot.json` | File path for the persisted browse snapshot. Ignored when `PersistSnapshot` is `false`. |
|
||||||
|
|
||||||
The connection string is not treated as a secret in dev (`Integrated
|
The connection string is not treated as a secret in dev (`Integrated
|
||||||
Security`), but production deployments that use SQL authentication should set
|
Security`), but production deployments that use SQL authentication should set
|
||||||
|
|||||||
@@ -60,7 +60,15 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid.
|
|||||||
"Galaxy": {
|
"Galaxy": {
|
||||||
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
|
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
|
||||||
"CommandTimeoutSeconds": 60,
|
"CommandTimeoutSeconds": 60,
|
||||||
"DashboardRefreshIntervalSeconds": 30
|
"DashboardRefreshIntervalSeconds": 30,
|
||||||
|
"PersistSnapshot": true,
|
||||||
|
"SnapshotCachePath": "C:\\ProgramData\\MxGateway\\galaxy-snapshot.json"
|
||||||
|
},
|
||||||
|
"Alarms": {
|
||||||
|
"Enabled": false,
|
||||||
|
"SubscriptionExpression": "",
|
||||||
|
"DefaultArea": "",
|
||||||
|
"ReconcileIntervalSeconds": 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,10 +172,24 @@ at startup.
|
|||||||
| `MxGateway:Galaxy:ConnectionString` | `Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;` | SQL Server connection string for the Galaxy Repository (`ZB`) used by the `GalaxyRepository` browse RPCs. Override in production via `MxGateway__Galaxy__ConnectionString`. |
|
| `MxGateway:Galaxy:ConnectionString` | `Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;` | SQL Server connection string for the Galaxy Repository (`ZB`) used by the `GalaxyRepository` browse RPCs. Override in production via `MxGateway__Galaxy__ConnectionString`. |
|
||||||
| `MxGateway:Galaxy:CommandTimeoutSeconds` | `60` | Per-command SQL timeout for all Galaxy browse RPCs. |
|
| `MxGateway:Galaxy:CommandTimeoutSeconds` | `60` | Per-command SQL timeout for all Galaxy browse RPCs. |
|
||||||
| `MxGateway:Galaxy:DashboardRefreshIntervalSeconds` | `30` | Interval between background refreshes of the dashboard Galaxy summary cache. SQL is hit at most once per interval regardless of dashboard render rate. |
|
| `MxGateway:Galaxy:DashboardRefreshIntervalSeconds` | `30` | Interval between background refreshes of the dashboard Galaxy summary cache. SQL is hit at most once per interval regardless of dashboard render rate. |
|
||||||
|
| `MxGateway:Galaxy:PersistSnapshot` | `true` | Persists the latest successful Galaxy browse dataset to disk. When `true`, the cache reloads that snapshot at startup so clients can still browse last-known data while the Galaxy database is unreachable. The restored data is served with `Stale` status until a live query confirms it. |
|
||||||
|
| `MxGateway:Galaxy:SnapshotCachePath` | `C:\ProgramData\MxGateway\galaxy-snapshot.json` | File path for the persisted Galaxy browse snapshot. Ignored when `PersistSnapshot` is `false`. The snapshot is written atomically (temp file plus rename). |
|
||||||
|
|
||||||
See [Galaxy Repository Browse](./GalaxyRepository.md) for the RPC surface and
|
See [Galaxy Repository Browse](./GalaxyRepository.md) for the RPC surface and
|
||||||
behavior.
|
behavior.
|
||||||
|
|
||||||
|
## Alarm Options
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `MxGateway:Alarms:Enabled` | `false` | Gates the gateway's always-on central alarm monitor. When `true`, the gateway opens one gateway-owned worker session dedicated to alarms, caches the active-alarm set, and fans it out to every client through the `StreamAlarms` RPC — no client opens its own session to see alarms. |
|
||||||
|
| `MxGateway:Alarms:SubscriptionExpression` | _(empty)_ | AVEVA alarm-subscription expression the monitor subscribes on startup, in canonical `\\<machine>\Galaxy!<area>` form. The literal `Galaxy` provider is correct regardless of the Galaxy database name. When empty and `Enabled` is `true`, the gateway falls back to `\\<MachineName>\Galaxy!<DefaultArea>` if `DefaultArea` is set. |
|
||||||
|
| `MxGateway:Alarms:DefaultArea` | _(empty)_ | Area name used to compose a default subscription when `SubscriptionExpression` is empty. If both are empty while `Enabled` is `true`, the monitor faults with a configuration diagnostic. |
|
||||||
|
| `MxGateway:Alarms:ReconcileIntervalSeconds` | `30` | How often the monitor reconciles its in-process alarm cache against the worker's authoritative active-alarm snapshot, catching transitions the live poll-and-diff feed missed. Floored at 5 seconds. |
|
||||||
|
|
||||||
|
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
|
||||||
|
`StreamAlarms` are session-less RPCs served by the monitor.
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Gateway Process Detailed Design](./GatewayProcessDesign.md)
|
- [Gateway Process Detailed Design](./GatewayProcessDesign.md)
|
||||||
|
|||||||
@@ -274,28 +274,32 @@ diagnostic session/worker views.
|
|||||||
|
|
||||||
### Alarms page
|
### Alarms page
|
||||||
|
|
||||||
`/dashboard/alarms` lists the alarms the dashboard session's worker currently
|
`/dashboard/alarms` lists the alarms the gateway's central alarm monitor
|
||||||
reports as Active or ActiveAcked, refreshed every three seconds. It defaults to
|
currently holds as Active or ActiveAcked, refreshed every three seconds. It
|
||||||
showing unacknowledged `Active` alarms; filters add acknowledged alarms and
|
defaults to showing unacknowledged `Active` alarms; filters add acknowledged
|
||||||
narrow by area, severity range, and a reference/source/description text search.
|
alarms and narrow by area, severity range, and a reference/source/description
|
||||||
Cleared alarms are not retained — the gateway holds no alarm-history store, so
|
text search. Cleared alarms are not retained — the gateway holds no
|
||||||
the page reflects only the live active set. The page is read-only; it does not
|
alarm-history store, so the page reflects only the live active set. The page is
|
||||||
acknowledge alarms. If `MxGateway:Alarms:Enabled` is false the session is never
|
read-only; it does not acknowledge alarms. If `MxGateway:Alarms:Enabled` is
|
||||||
subscribed to an alarm provider, and the page says so instead of showing an
|
false the central monitor never starts, and the page says so instead of showing
|
||||||
empty list with no explanation.
|
an empty list with no explanation.
|
||||||
|
|
||||||
### Live data source
|
### Live data source
|
||||||
|
|
||||||
Both the Browse subscription panel and the Alarms page read live MXAccess data
|
Both the Browse subscription panel and the Alarms page read live MXAccess data
|
||||||
through `IDashboardLiveDataService` (`DashboardLiveDataService`). It owns one
|
through `IDashboardLiveDataService` (`DashboardLiveDataService`). For tag data
|
||||||
shared gateway session for the whole dashboard, opened lazily on first use via
|
it owns one shared gateway session for the whole dashboard, opened lazily on
|
||||||
`ISessionManager` and re-opened transparently when it faults or its lease
|
first use via `ISessionManager` and re-opened transparently when it faults or
|
||||||
expires. One session means one worker process backs every dashboard circuit;
|
its lease expires. One session means one worker process backs every dashboard
|
||||||
all access is serialised so the worker sees one in-flight command at a time.
|
circuit; all access is serialised so the worker sees one in-flight command at a
|
||||||
Tag reads go through `GatewaySession.SubscribeBulkAsync` / `ReadBulkAsync`;
|
time. Tag reads go through `GatewaySession.SubscribeBulkAsync` / `ReadBulkAsync`.
|
||||||
alarm queries go through `IAlarmRpcDispatcher`. Alarm subscription is the
|
|
||||||
gateway's existing auto-subscribe-on-open hook, so the dashboard session is
|
The Alarms page does **not** use the dashboard session: alarm data comes from
|
||||||
alarm-subscribed only when `MxGateway:Alarms:Enabled` is set.
|
the gateway's always-on central monitor. `QueryAlarmsAsync` reads
|
||||||
|
`IGatewayAlarmService.CurrentAlarms` — the monitor's in-process cache — so the
|
||||||
|
dashboard sees the same active-alarm set as every `StreamAlarms` client, with
|
||||||
|
no per-dashboard alarm subscription. When `MxGateway:Alarms:Enabled` is false
|
||||||
|
the monitor never starts and the cache stays empty.
|
||||||
|
|
||||||
### API keys page
|
### API keys page
|
||||||
|
|
||||||
|
|||||||
@@ -293,6 +293,18 @@ path and writes a JSON report under `artifacts/e2e/`:
|
|||||||
write command is rejected — e.g. against a gateway whose worker predates
|
write command is rejected — e.g. against a gateway whose worker predates
|
||||||
write support (`MxAccessCommandExecutor` returning `InvalidRequest` for
|
write support (`MxAccessCommandExecutor` returning `InvalidRequest` for
|
||||||
`Write`/`Write2`/`WriteSecured`/`WriteSecured2`).
|
`Write`/`Write2`/`WriteSecured`/`WriteSecured2`).
|
||||||
|
8. **Alarm feed + acknowledge** — *opt-in (`-VerifyAlarms`).* Runs after the
|
||||||
|
stream phase. Exercises the two session-less alarm subcommands against the
|
||||||
|
gateway's central alarm monitor: `stream-alarms` reads a bounded slice of
|
||||||
|
the feed (`-AlarmStreamMax`, default 1 — the feed's first message always
|
||||||
|
arrives immediately, whereas later ones depend on live transitions) and
|
||||||
|
asserts at least one `AlarmFeedMessage`; `acknowledge-alarm` acknowledges
|
||||||
|
`-AlarmReference` (default `Galaxy!TestArea.TestMachine_001.TestAlarm001`)
|
||||||
|
and asserts the RPC round-trips. The native ack outcome is not asserted —
|
||||||
|
it depends on whether that alarm is currently active.
|
||||||
|
|
||||||
|
It is opt-in because it depends on the gateway's central alarm monitor
|
||||||
|
being enabled (`MxGateway:Alarms:Enabled`) and a live alarm provider.
|
||||||
|
|
||||||
Each client CLI is driven through one long-lived `batch` process. Every CLI
|
Each client CLI is driven through one long-lived `batch` process. Every CLI
|
||||||
exposes a `batch` subcommand: a process that reads one command line from stdin,
|
exposes a `batch` subcommand: a process that reads one command line from stdin,
|
||||||
@@ -329,6 +341,8 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -SkipB
|
|||||||
# Write round-trip (opt-in): point at a writable scalar attribute and its
|
# Write round-trip (opt-in): point at a writable scalar attribute and its
|
||||||
# value type.
|
# value type.
|
||||||
powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -VerifyWrite -WriteAttribute TestChangingInt -WriteType int32
|
powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -VerifyWrite -WriteAttribute TestChangingInt -WriteType int32
|
||||||
|
# Alarm feed + acknowledge (opt-in): needs MxGateway:Alarms:Enabled on the gateway.
|
||||||
|
powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -VerifyAlarms -AlarmReference "Galaxy!TestArea.TestMachine_001.TestAlarm001"
|
||||||
# Auth rejection: also assert an insufficient-scope key is denied.
|
# Auth rejection: also assert an insufficient-scope key is denied.
|
||||||
powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -RejectScopeApiKeyEnv MXGATEWAY_READONLY_API_KEY
|
powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -RejectScopeApiKeyEnv MXGATEWAY_READONLY_API_KEY
|
||||||
# Run all five clients concurrently as isolated child processes.
|
# Run all five clients concurrently as isolated child processes.
|
||||||
|
|||||||
+6
-6
@@ -29,7 +29,7 @@ A second gRPC service, `GalaxyRepositoryGrpcService`, is mapped alongside it. It
|
|||||||
|
|
||||||
## RPC Handlers
|
## RPC Handlers
|
||||||
|
|
||||||
`MxAccessGatewayService` derives from the generated `MxAccessGateway.MxAccessGatewayBase` and implements every RPC declared in `mxaccess_gateway.proto` — six in total: `OpenSession`, `CloseSession`, `Invoke`, `StreamEvents`, `AcknowledgeAlarm`, and `QueryActiveAlarms`. The proto contract itself is documented in [Contracts](./Contracts.md); this section covers only what the server-side handler does on top of that contract.
|
`MxAccessGatewayService` derives from the generated `MxAccessGateway.MxAccessGatewayBase` and implements every RPC declared in `mxaccess_gateway.proto` — six in total: `OpenSession`, `CloseSession`, `Invoke`, `StreamEvents`, `AcknowledgeAlarm`, and `StreamAlarms`. The proto contract itself is documented in [Contracts](./Contracts.md); this section covers only what the server-side handler does on top of that contract.
|
||||||
|
|
||||||
Public gRPC send and receive message sizes are configured from
|
Public gRPC send and receive message sizes are configured from
|
||||||
`MxGateway:Protocol:MaxGrpcMessageBytes` (default 16 MiB). Official clients use
|
`MxGateway:Protocol:MaxGrpcMessageBytes` (default 16 MiB). Official clients use
|
||||||
@@ -88,11 +88,11 @@ Carrying the enqueue timestamp into the worker layer is what lets queue-wait tim
|
|||||||
|
|
||||||
### `AcknowledgeAlarm`
|
### `AcknowledgeAlarm`
|
||||||
|
|
||||||
`AcknowledgeAlarm` is a unary RPC that acknowledges a single alarm. The handler validates `session_id` and `alarm_full_reference` inline (it does not run through `MxAccessGrpcRequestValidator`, because the alarm surface routes through `IAlarmRpcDispatcher` rather than the generic `Invoke` path), resolves the session, then delegates to the registered `IAlarmRpcDispatcher`. The production `WorkerAlarmRpcDispatcher` routes the ack over the worker IPC by GUID (`AcknowledgeAlarmCommand`) when the reference parses as a canonical GUID, or by `Provider!Group.Tag` reference (`AcknowledgeAlarmByNameCommand`) otherwise. The handler-level RPC behaviour and the alarm contract itself are documented in [Alarm Client Discovery](./AlarmClientDiscovery.md).
|
`AcknowledgeAlarm` is a unary, **session-less** RPC that acknowledges a single alarm. The handler validates `alarm_full_reference` inline (it does not run through `MxAccessGrpcRequestValidator`) and delegates to `IGatewayAlarmService.AcknowledgeAsync`. The always-on `GatewayAlarmMonitor` routes the ack over its own gateway-managed worker session — clients no longer open a session to acknowledge an alarm. A reference that parses as a canonical GUID forwards to `AcknowledgeAlarmCommand`; a `Provider!Group.Tag` reference forwards to `AcknowledgeAlarmByNameCommand`.
|
||||||
|
|
||||||
### `QueryActiveAlarms`
|
### `StreamAlarms`
|
||||||
|
|
||||||
`QueryActiveAlarms` is a server-streaming RPC that returns an `ActiveAlarmSnapshot` per currently active alarm. The handler validates `session_id` inline, resolves the session, and delegates to `IAlarmRpcDispatcher`; `WorkerAlarmRpcDispatcher` issues a `QueryActiveAlarmsCommand` over the worker IPC and streams each snapshot from the worker reply.
|
`StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree.
|
||||||
|
|
||||||
## Validation Rules
|
## Validation Rules
|
||||||
|
|
||||||
@@ -104,8 +104,8 @@ Carrying the enqueue timestamp into the worker layer is what lets queue-wait tim
|
|||||||
| `CloseSession` | `session_id` must be non-empty. | `InvalidArgument` |
|
| `CloseSession` | `session_id` must be non-empty. | `InvalidArgument` |
|
||||||
| `StreamEvents` | `session_id` must be non-empty. | `InvalidArgument` |
|
| `StreamEvents` | `session_id` must be non-empty. | `InvalidArgument` |
|
||||||
| `Invoke` | `session_id` non-empty, `command` present, `kind` not `Unspecified`, payload oneof must match `kind`. | `InvalidArgument` |
|
| `Invoke` | `session_id` non-empty, `command` present, `kind` not `Unspecified`, payload oneof must match `kind`. | `InvalidArgument` |
|
||||||
| `AcknowledgeAlarm` | `session_id` and `alarm_full_reference` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` |
|
| `AcknowledgeAlarm` | `alarm_full_reference` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` |
|
||||||
| `QueryActiveAlarms` | `session_id` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` |
|
| `StreamAlarms` | No required fields — `alarm_filter_prefix` is optional. | — |
|
||||||
|
|
||||||
The payload-vs-kind check matters because the `MxCommand.payload` oneof is non-discriminated on the wire — a misaligned client could send `kind = Write` with a `Register` payload and silently confuse the worker. The validator turns that into a clear client error:
|
The payload-vs-kind check matters because the `MxCommand.payload` oneof is non-discriminated on the wire — a misaligned client could send `kind = Write` with a `Register` payload and silently confuse the worker. The validator turns that into a clear client error:
|
||||||
|
|
||||||
|
|||||||
+16
-5
@@ -120,13 +120,24 @@ snapshot interval without mutating session or worker state. The dashboard uses
|
|||||||
local Bootstrap CSS and JavaScript plus a small local stylesheet; it does not
|
local Bootstrap CSS and JavaScript plus a small local stylesheet; it does not
|
||||||
use a Blazor UI component library.
|
use a Blazor UI component library.
|
||||||
|
|
||||||
`/dashboard/browse` and `/dashboard/alarms` go beyond read-only snapshots: they
|
`/dashboard/browse` walks the `IGalaxyHierarchyCache` tree and reads subscribed
|
||||||
read live MXAccess data through `IDashboardLiveDataService`, which owns one
|
tag values live through `IDashboardLiveDataService`, which owns one shared,
|
||||||
shared, lazily-opened gateway session (and therefore one worker) for the whole
|
lazily-opened gateway session for the whole dashboard. `/dashboard/alarms`
|
||||||
dashboard. Browse walks the `IGalaxyHierarchyCache` tree and reads subscribed
|
reads the central alarm monitor's in-process cache directly. See
|
||||||
tag values; Alarms lists the worker's currently-active alarm set. See
|
|
||||||
`docs/GatewayDashboardDesign.md`.
|
`docs/GatewayDashboardDesign.md`.
|
||||||
|
|
||||||
|
The gateway runs an always-on central alarm monitor (`GatewayAlarmMonitor`):
|
||||||
|
one gateway-owned worker session subscribes the configured AVEVA alarm
|
||||||
|
provider, caches the active-alarm set (reconciled periodically against the
|
||||||
|
worker's snapshot), and fans it out to every client through the session-less
|
||||||
|
`StreamAlarms` RPC — the stream opens with the current active-alarm snapshot,
|
||||||
|
then streams live transitions. `AcknowledgeAlarm` is session-less and routes
|
||||||
|
through the monitor. Clients never open a worker session to see alarms, and
|
||||||
|
alarm monitoring is independent of client lifecycle; the monitor re-opens its
|
||||||
|
session if the worker faults. Gated by `MxGateway:Alarms:Enabled` — see
|
||||||
|
`docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule
|
||||||
|
for the alarm subsystem.
|
||||||
|
|
||||||
Dashboard routes use the same API-key verifier as gRPC. `/dashboard/login`
|
Dashboard routes use the same API-key verifier as gRPC. `/dashboard/login`
|
||||||
accepts the API key in a form body, validates the configured `admin` scope,
|
accepts the API key in a form body, validates the configured `admin` scope,
|
||||||
and issues an HTTP-only secure cookie for subsequent dashboard requests.
|
and issues an HTTP-only secure cookie for subsequent dashboard requests.
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ Drives the .NET, Go, Rust, Python, and Java client CLIs against a running
|
|||||||
gateway + worker. For each language the script exercises session open/close,
|
gateway + worker. For each language the script exercises session open/close,
|
||||||
register, bulk subscribe/unsubscribe, per-tag add-item/advise, event
|
register, bulk subscribe/unsubscribe, per-tag add-item/advise, event
|
||||||
streaming, a write round-trip with value assertion, error-path (parity)
|
streaming, a write round-trip with value assertion, error-path (parity)
|
||||||
checks, and API-key auth rejection.
|
checks, and API-key auth rejection. With -VerifyAlarms it also exercises the
|
||||||
|
session-less stream-alarms and acknowledge-alarm subcommands against the
|
||||||
|
gateway's central alarm monitor.
|
||||||
|
|
||||||
Each client CLI is driven through one long-lived `batch` process: the harness
|
Each client CLI is driven through one long-lived `batch` process: the harness
|
||||||
writes one command line to its stdin and reads the JSON result back, so the
|
writes one command line to its stdin and reads the JSON result back, so the
|
||||||
@@ -60,6 +62,18 @@ param(
|
|||||||
[string]$WriteType = "int32",
|
[string]$WriteType = "int32",
|
||||||
[int]$WriteValueBase = 424200,
|
[int]$WriteValueBase = 424200,
|
||||||
[int]$WriteEchoMaxEvents = 200,
|
[int]$WriteEchoMaxEvents = 200,
|
||||||
|
# Alarm feed + acknowledge coverage. Opt-in because it depends on the
|
||||||
|
# gateway's central alarm monitor being enabled (MxGateway:Alarms:Enabled)
|
||||||
|
# and a live alarm provider: stream-alarms reads the monitor's snapshot and
|
||||||
|
# acknowledge-alarm acknowledges -AlarmReference. Both RPCs are session-less
|
||||||
|
# — they exercise the gateway's always-on monitor, not a client session.
|
||||||
|
[switch]$VerifyAlarms,
|
||||||
|
[string]$AlarmReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001",
|
||||||
|
# Messages to read from the central alarm feed. 1 is enough to confirm the
|
||||||
|
# subcommand round-trips: the feed's first message (an active-alarm
|
||||||
|
# snapshot, or snapshot-complete when no alarms are active) always arrives
|
||||||
|
# immediately, whereas later messages depend on live alarm transitions.
|
||||||
|
[int]$AlarmStreamMax = 1,
|
||||||
# Error-path (parity) checks.
|
# Error-path (parity) checks.
|
||||||
[switch]$SkipParity,
|
[switch]$SkipParity,
|
||||||
# API-key auth rejection checks.
|
# API-key auth rejection checks.
|
||||||
@@ -118,6 +132,10 @@ if ($WriteEchoMaxEvents -lt 1) {
|
|||||||
throw "WriteEchoMaxEvents must be greater than zero."
|
throw "WriteEchoMaxEvents must be greater than zero."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($AlarmStreamMax -lt 1) {
|
||||||
|
throw "AlarmStreamMax must be greater than zero."
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($client in $Clients) {
|
foreach ($client in $Clients) {
|
||||||
if ($validClients -notcontains $client) {
|
if ($validClients -notcontains $client) {
|
||||||
throw "Unsupported client '$client'. Supported clients: $($validClients -join ', ')."
|
throw "Unsupported client '$client'. Supported clients: $($validClients -join ', ')."
|
||||||
@@ -327,6 +345,25 @@ function Get-StreamEvents {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Counts the messages in a stream-alarms reply. The CLIs shape the aggregate
|
||||||
|
# JSON differently: .NET nests them under `alarms`, Rust under `messages` with
|
||||||
|
# a `messageCount`, Python under `messages`; Go and Java emit one AlarmFeedMessage
|
||||||
|
# object per line (Read-JsonObject collapses NDJSON into a bare array).
|
||||||
|
function Get-AlarmMessageCount {
|
||||||
|
param(
|
||||||
|
[string]$Client,
|
||||||
|
[object]$Json
|
||||||
|
)
|
||||||
|
|
||||||
|
switch ($Client) {
|
||||||
|
"dotnet" { return @($Json.alarms).Count }
|
||||||
|
"go" { return @($Json).Count }
|
||||||
|
"rust" { return [int]$Json.messageCount }
|
||||||
|
"python" { return @($Json.messages).Count }
|
||||||
|
"java" { return @($Json).Count }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Get-PropertyValue {
|
function Get-PropertyValue {
|
||||||
param(
|
param(
|
||||||
[object]$Object,
|
[object]$Object,
|
||||||
@@ -564,6 +601,13 @@ function Get-ClientCommand {
|
|||||||
$arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--type", $Values.valueType, "--value", $Values.value)
|
$arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--type", $Values.valueType, "--value", $Values.value)
|
||||||
} elseif ($Operation -eq "stream-events") {
|
} elseif ($Operation -eq "stream-events") {
|
||||||
$arguments += @("--session-id", $Values.sessionId, "--max-events", "$streamMaxEvents")
|
$arguments += @("--session-id", $Values.sessionId, "--max-events", "$streamMaxEvents")
|
||||||
|
} elseif ($Operation -eq "stream-alarms") {
|
||||||
|
$arguments += @("--max-events", "$streamMaxEvents")
|
||||||
|
if ($Values.ContainsKey("filterPrefix")) { $arguments += @("--filter-prefix", $Values.filterPrefix) }
|
||||||
|
} elseif ($Operation -eq "acknowledge-alarm") {
|
||||||
|
$arguments += @("--reference", $Values.alarmReference)
|
||||||
|
if ($Values.ContainsKey("comment")) { $arguments += @("--comment", $Values.comment) }
|
||||||
|
if ($Values.ContainsKey("operator")) { $arguments += @("--operator", $Values.operator) }
|
||||||
} elseif ($Operation -eq "close-session") {
|
} elseif ($Operation -eq "close-session") {
|
||||||
$arguments += @("--session-id", $Values.sessionId)
|
$arguments += @("--session-id", $Values.sessionId)
|
||||||
}
|
}
|
||||||
@@ -600,6 +644,13 @@ function Get-ClientCommand {
|
|||||||
$arguments += @("-session-id", $Values.sessionId, "-server-handle", "$($Values.serverHandle)", "-item-handle", "$($Values.itemHandle)", "-type", $Values.valueType, "-value", $Values.value)
|
$arguments += @("-session-id", $Values.sessionId, "-server-handle", "$($Values.serverHandle)", "-item-handle", "$($Values.itemHandle)", "-type", $Values.valueType, "-value", $Values.value)
|
||||||
} elseif ($Operation -eq "stream-events") {
|
} elseif ($Operation -eq "stream-events") {
|
||||||
$arguments += @("-session-id", $Values.sessionId, "-limit", "$streamMaxEvents")
|
$arguments += @("-session-id", $Values.sessionId, "-limit", "$streamMaxEvents")
|
||||||
|
} elseif ($Operation -eq "stream-alarms") {
|
||||||
|
$arguments += @("-limit", "$streamMaxEvents")
|
||||||
|
if ($Values.ContainsKey("filterPrefix")) { $arguments += @("-filter-prefix", $Values.filterPrefix) }
|
||||||
|
} elseif ($Operation -eq "acknowledge-alarm") {
|
||||||
|
$arguments += @("-reference", $Values.alarmReference)
|
||||||
|
if ($Values.ContainsKey("comment")) { $arguments += @("-comment", $Values.comment) }
|
||||||
|
if ($Values.ContainsKey("operator")) { $arguments += @("-operator", $Values.operator) }
|
||||||
} elseif ($Operation -eq "close-session") {
|
} elseif ($Operation -eq "close-session") {
|
||||||
$arguments += @("-session-id", $Values.sessionId)
|
$arguments += @("-session-id", $Values.sessionId)
|
||||||
}
|
}
|
||||||
@@ -637,6 +688,13 @@ function Get-ClientCommand {
|
|||||||
$arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--value-type", $Values.valueType, "--value", $Values.value)
|
$arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--value-type", $Values.valueType, "--value", $Values.value)
|
||||||
} elseif ($Operation -eq "stream-events") {
|
} elseif ($Operation -eq "stream-events") {
|
||||||
$arguments += @("--session-id", $Values.sessionId, "--max-events", "$streamMaxEvents")
|
$arguments += @("--session-id", $Values.sessionId, "--max-events", "$streamMaxEvents")
|
||||||
|
} elseif ($Operation -eq "stream-alarms") {
|
||||||
|
$arguments += @("--max-events", "$streamMaxEvents")
|
||||||
|
if ($Values.ContainsKey("filterPrefix")) { $arguments += @("--filter-prefix", $Values.filterPrefix) }
|
||||||
|
} elseif ($Operation -eq "acknowledge-alarm") {
|
||||||
|
$arguments += @("--reference", $Values.alarmReference)
|
||||||
|
if ($Values.ContainsKey("comment")) { $arguments += @("--comment", $Values.comment) }
|
||||||
|
if ($Values.ContainsKey("operator")) { $arguments += @("--operator", $Values.operator) }
|
||||||
} elseif ($Operation -eq "close-session") {
|
} elseif ($Operation -eq "close-session") {
|
||||||
$arguments += @("--session-id", $Values.sessionId)
|
$arguments += @("--session-id", $Values.sessionId)
|
||||||
}
|
}
|
||||||
@@ -673,6 +731,13 @@ function Get-ClientCommand {
|
|||||||
$arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--type", $Values.valueType, "--value", $Values.value)
|
$arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--type", $Values.valueType, "--value", $Values.value)
|
||||||
} elseif ($Operation -eq "stream-events") {
|
} elseif ($Operation -eq "stream-events") {
|
||||||
$arguments += @("--session-id", $Values.sessionId, "--max-events", "$streamMaxEvents", "--timeout", "$pythonStreamTimeout")
|
$arguments += @("--session-id", $Values.sessionId, "--max-events", "$streamMaxEvents", "--timeout", "$pythonStreamTimeout")
|
||||||
|
} elseif ($Operation -eq "stream-alarms") {
|
||||||
|
$arguments += @("--max-messages", "$streamMaxEvents", "--timeout", "$pythonStreamTimeout")
|
||||||
|
if ($Values.ContainsKey("filterPrefix")) { $arguments += @("--filter-prefix", $Values.filterPrefix) }
|
||||||
|
} elseif ($Operation -eq "acknowledge-alarm") {
|
||||||
|
$arguments += @("--reference", $Values.alarmReference)
|
||||||
|
if ($Values.ContainsKey("comment")) { $arguments += @("--comment", $Values.comment) }
|
||||||
|
if ($Values.ContainsKey("operator")) { $arguments += @("--operator", $Values.operator) }
|
||||||
} elseif ($Operation -eq "close-session") {
|
} elseif ($Operation -eq "close-session") {
|
||||||
$arguments += @("--session-id", $Values.sessionId)
|
$arguments += @("--session-id", $Values.sessionId)
|
||||||
}
|
}
|
||||||
@@ -712,6 +777,13 @@ function Get-ClientCommand {
|
|||||||
$cliArgs += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--type", $Values.valueType, "--value", $Values.value)
|
$cliArgs += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--type", $Values.valueType, "--value", $Values.value)
|
||||||
} elseif ($Operation -eq "stream-events") {
|
} elseif ($Operation -eq "stream-events") {
|
||||||
$cliArgs += @("--session-id", $Values.sessionId, "--limit", "$streamMaxEvents")
|
$cliArgs += @("--session-id", $Values.sessionId, "--limit", "$streamMaxEvents")
|
||||||
|
} elseif ($Operation -eq "stream-alarms") {
|
||||||
|
$cliArgs += @("--limit", "$streamMaxEvents")
|
||||||
|
if ($Values.ContainsKey("filterPrefix")) { $cliArgs += @("--filter-prefix", $Values.filterPrefix) }
|
||||||
|
} elseif ($Operation -eq "acknowledge-alarm") {
|
||||||
|
$cliArgs += @("--reference", $Values.alarmReference)
|
||||||
|
if ($Values.ContainsKey("comment")) { $cliArgs += @("--comment", $Values.comment) }
|
||||||
|
if ($Values.ContainsKey("operator")) { $cliArgs += @("--operator", $Values.operator) }
|
||||||
} elseif ($Operation -eq "close-session") {
|
} elseif ($Operation -eq "close-session") {
|
||||||
$cliArgs += @("--session-id", $Values.sessionId)
|
$cliArgs += @("--session-id", $Values.sessionId)
|
||||||
}
|
}
|
||||||
@@ -801,6 +873,36 @@ function Get-DryRunReply {
|
|||||||
default { return [pscustomobject]@{ events = $events } }
|
default { return [pscustomobject]@{ events = $events } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"stream-alarms" {
|
||||||
|
# Synthesize an active-alarm snapshot followed by the
|
||||||
|
# snapshot-complete sentinel. The reply is shaped per client:
|
||||||
|
# Go and Java emit one message object per line (Read-JsonObject
|
||||||
|
# collapses NDJSON to a bare array), Rust aggregates under
|
||||||
|
# `messages` with a `messageCount`, Python under `messages`, and
|
||||||
|
# .NET under `alarms`.
|
||||||
|
$activeAlarm = [pscustomobject]@{
|
||||||
|
activeAlarm = [pscustomobject]@{
|
||||||
|
alarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001"
|
||||||
|
currentState = "ALARM_CONDITION_STATE_ACTIVE"
|
||||||
|
severity = 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$snapshotComplete = [pscustomobject]@{ snapshotComplete = $true }
|
||||||
|
$messages = @($activeAlarm, $snapshotComplete)
|
||||||
|
switch ($Client) {
|
||||||
|
"go" { return ,$messages }
|
||||||
|
"java" { return ,$messages }
|
||||||
|
"rust" { return [pscustomobject]@{ messageCount = $messages.Count; messages = $messages } }
|
||||||
|
"dotnet" { return [pscustomobject]@{ alarms = $messages } }
|
||||||
|
default { return [pscustomobject]@{ messages = $messages } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"acknowledge-alarm" {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
rawReply = [pscustomobject]@{ hresult = 0; diagnosticMessage = "dry-run ack" }
|
||||||
|
reply = [pscustomobject]@{ hresult = 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
default { return [pscustomobject]@{ ok = $true; reply = [pscustomobject]@{} } }
|
default { return [pscustomobject]@{ ok = $true; reply = [pscustomobject]@{} } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1053,6 +1155,7 @@ function Invoke-ClientFlow {
|
|||||||
addedItems = @()
|
addedItems = @()
|
||||||
eventCount = 0
|
eventCount = 0
|
||||||
write = $null
|
write = $null
|
||||||
|
alarms = $null
|
||||||
parity = @()
|
parity = @()
|
||||||
auth = @()
|
auth = @()
|
||||||
closed = $false
|
closed = $false
|
||||||
@@ -1285,6 +1388,35 @@ function Invoke-ClientFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Alarm feed + acknowledge -------------------------------------
|
||||||
|
# Session-less RPCs against the gateway's always-on central alarm
|
||||||
|
# monitor. Opt-in (-VerifyAlarms) because it needs the monitor enabled
|
||||||
|
# (MxGateway:Alarms:Enabled) and a live alarm provider.
|
||||||
|
if ($VerifyAlarms) {
|
||||||
|
$alarmStreamJson = Invoke-ClientOperation -Client $Client -Operation "stream-alarms" -Values @{
|
||||||
|
maxEvents = $AlarmStreamMax
|
||||||
|
}
|
||||||
|
$alarmMessageCount = Get-AlarmMessageCount -Client $Client -Json $alarmStreamJson
|
||||||
|
if ($alarmMessageCount -lt 1) {
|
||||||
|
throw "The $Client stream-alarms command returned no alarm-feed messages."
|
||||||
|
}
|
||||||
|
|
||||||
|
# The acknowledge round-trips against the central monitor; the
|
||||||
|
# native ack outcome depends on whether the referenced alarm is
|
||||||
|
# currently active, so only the RPC's success is asserted here.
|
||||||
|
Invoke-ClientOperation -Client $Client -Operation "acknowledge-alarm" -Values @{
|
||||||
|
alarmReference = $AlarmReference
|
||||||
|
comment = "e2e-matrix"
|
||||||
|
operator = "mxgw-e2e"
|
||||||
|
} | Out-Null
|
||||||
|
|
||||||
|
$clientResult.alarms = [ordered]@{
|
||||||
|
streamMessageCount = $alarmMessageCount
|
||||||
|
acknowledgeReference = $AlarmReference
|
||||||
|
acknowledged = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# --- Error-path (parity) checks -----------------------------------
|
# --- Error-path (parity) checks -----------------------------------
|
||||||
# MXAccess parity: an invalid item handle and an unknown session must
|
# MXAccess parity: an invalid item handle and an unknown session must
|
||||||
# both be rejected rather than silently succeeding.
|
# both be rejected rather than silently succeeding.
|
||||||
@@ -1391,6 +1523,8 @@ function Get-ChildArgumentList {
|
|||||||
"-WriteType", $WriteType,
|
"-WriteType", $WriteType,
|
||||||
"-WriteValueBase", "$WriteValueBase",
|
"-WriteValueBase", "$WriteValueBase",
|
||||||
"-WriteEchoMaxEvents", "$WriteEchoMaxEvents",
|
"-WriteEchoMaxEvents", "$WriteEchoMaxEvents",
|
||||||
|
"-AlarmReference", $AlarmReference,
|
||||||
|
"-AlarmStreamMax", "$AlarmStreamMax",
|
||||||
"-ReportPath", $ChildReportPath,
|
"-ReportPath", $ChildReportPath,
|
||||||
"-EmitReport"
|
"-EmitReport"
|
||||||
)
|
)
|
||||||
@@ -1400,6 +1534,7 @@ function Get-ChildArgumentList {
|
|||||||
if ($SkipStream) { $childArgs += "-SkipStream" }
|
if ($SkipStream) { $childArgs += "-SkipStream" }
|
||||||
if ($SkipBulk) { $childArgs += "-SkipBulk" }
|
if ($SkipBulk) { $childArgs += "-SkipBulk" }
|
||||||
if ($VerifyWrite) { $childArgs += "-VerifyWrite" }
|
if ($VerifyWrite) { $childArgs += "-VerifyWrite" }
|
||||||
|
if ($VerifyAlarms) { $childArgs += "-VerifyAlarms" }
|
||||||
if ($SkipParity) { $childArgs += "-SkipParity" }
|
if ($SkipParity) { $childArgs += "-SkipParity" }
|
||||||
if ($SkipAuth) { $childArgs += "-SkipAuth" }
|
if ($SkipAuth) { $childArgs += "-SkipAuth" }
|
||||||
if ($DryRun) { $childArgs += "-DryRun" }
|
if ($DryRun) { $childArgs += "-DryRun" }
|
||||||
@@ -1479,6 +1614,7 @@ if ($Parallel -and $Clients.Count -gt 1) {
|
|||||||
skipStream = [bool]$SkipStream
|
skipStream = [bool]$SkipStream
|
||||||
skipBulk = [bool]$SkipBulk
|
skipBulk = [bool]$SkipBulk
|
||||||
verifyWrite = [bool]$VerifyWrite
|
verifyWrite = [bool]$VerifyWrite
|
||||||
|
verifyAlarms = [bool]$VerifyAlarms
|
||||||
skipParity = [bool]$SkipParity
|
skipParity = [bool]$SkipParity
|
||||||
skipAuth = [bool]$SkipAuth
|
skipAuth = [bool]$SkipAuth
|
||||||
writeAttribute = $WriteAttribute
|
writeAttribute = $WriteAttribute
|
||||||
@@ -1540,6 +1676,7 @@ $run = [ordered]@{
|
|||||||
skipStream = [bool]$SkipStream
|
skipStream = [bool]$SkipStream
|
||||||
skipBulk = [bool]$SkipBulk
|
skipBulk = [bool]$SkipBulk
|
||||||
verifyWrite = [bool]$VerifyWrite
|
verifyWrite = [bool]$VerifyWrite
|
||||||
|
verifyAlarms = [bool]$VerifyAlarms
|
||||||
skipParity = [bool]$SkipParity
|
skipParity = [bool]$SkipParity
|
||||||
skipAuth = [bool]$SkipAuth
|
skipAuth = [bool]$SkipAuth
|
||||||
writeAttribute = $WriteAttribute
|
writeAttribute = $WriteAttribute
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -69,9 +69,9 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply> __Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply.Parser));
|
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply> __Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply.Parser));
|
||||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest> __Marshaller_mxaccess_gateway_v1_QueryActiveAlarmsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest.Parser));
|
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.StreamAlarmsRequest> __Marshaller_mxaccess_gateway_v1_StreamAlarmsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.StreamAlarmsRequest.Parser));
|
||||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot> __Marshaller_mxaccess_gateway_v1_ActiveAlarmSnapshot = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot.Parser));
|
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.AlarmFeedMessage> __Marshaller_mxaccess_gateway_v1_AlarmFeedMessage = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.AlarmFeedMessage.Parser));
|
||||||
|
|
||||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
static readonly grpc::Method<global::MxGateway.Contracts.Proto.OpenSessionRequest, global::MxGateway.Contracts.Proto.OpenSessionReply> __Method_OpenSession = new grpc::Method<global::MxGateway.Contracts.Proto.OpenSessionRequest, global::MxGateway.Contracts.Proto.OpenSessionReply>(
|
static readonly grpc::Method<global::MxGateway.Contracts.Proto.OpenSessionRequest, global::MxGateway.Contracts.Proto.OpenSessionReply> __Method_OpenSession = new grpc::Method<global::MxGateway.Contracts.Proto.OpenSessionRequest, global::MxGateway.Contracts.Proto.OpenSessionReply>(
|
||||||
@@ -114,12 +114,12 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
__Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmReply);
|
__Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmReply);
|
||||||
|
|
||||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
static readonly grpc::Method<global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest, global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot> __Method_QueryActiveAlarms = new grpc::Method<global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest, global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot>(
|
static readonly grpc::Method<global::MxGateway.Contracts.Proto.StreamAlarmsRequest, global::MxGateway.Contracts.Proto.AlarmFeedMessage> __Method_StreamAlarms = new grpc::Method<global::MxGateway.Contracts.Proto.StreamAlarmsRequest, global::MxGateway.Contracts.Proto.AlarmFeedMessage>(
|
||||||
grpc::MethodType.ServerStreaming,
|
grpc::MethodType.ServerStreaming,
|
||||||
__ServiceName,
|
__ServiceName,
|
||||||
"QueryActiveAlarms",
|
"StreamAlarms",
|
||||||
__Marshaller_mxaccess_gateway_v1_QueryActiveAlarmsRequest,
|
__Marshaller_mxaccess_gateway_v1_StreamAlarmsRequest,
|
||||||
__Marshaller_mxaccess_gateway_v1_ActiveAlarmSnapshot);
|
__Marshaller_mxaccess_gateway_v1_AlarmFeedMessage);
|
||||||
|
|
||||||
/// <summary>Service descriptor</summary>
|
/// <summary>Service descriptor</summary>
|
||||||
public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor
|
public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor
|
||||||
@@ -161,8 +161,19 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Session-less central alarm feed. The stream opens with the current
|
||||||
|
/// active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
/// `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
/// Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
/// fan out from the single monitor without opening a worker session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request received from the client.</param>
|
||||||
|
/// <param name="responseStream">Used for sending responses back to the client.</param>
|
||||||
|
/// <param name="context">The context of the server-side call handler being invoked.</param>
|
||||||
|
/// <returns>A task indicating completion of the handler.</returns>
|
||||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
public virtual global::System.Threading.Tasks.Task QueryActiveAlarms(global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest request, grpc::IServerStreamWriter<global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot> responseStream, grpc::ServerCallContext context)
|
public virtual global::System.Threading.Tasks.Task StreamAlarms(global::MxGateway.Contracts.Proto.StreamAlarmsRequest request, grpc::IServerStreamWriter<global::MxGateway.Contracts.Proto.AlarmFeedMessage> responseStream, grpc::ServerCallContext context)
|
||||||
{
|
{
|
||||||
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||||
}
|
}
|
||||||
@@ -286,15 +297,37 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
{
|
{
|
||||||
return CallInvoker.AsyncUnaryCall(__Method_AcknowledgeAlarm, null, options, request);
|
return CallInvoker.AsyncUnaryCall(__Method_AcknowledgeAlarm, null, options, request);
|
||||||
}
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Session-less central alarm feed. The stream opens with the current
|
||||||
|
/// active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
/// `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
/// Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
/// fan out from the single monitor without opening a worker session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||||
|
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||||
|
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||||
|
/// <returns>The call object.</returns>
|
||||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
public virtual grpc::AsyncServerStreamingCall<global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot> QueryActiveAlarms(global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
public virtual grpc::AsyncServerStreamingCall<global::MxGateway.Contracts.Proto.AlarmFeedMessage> StreamAlarms(global::MxGateway.Contracts.Proto.StreamAlarmsRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||||
{
|
{
|
||||||
return QueryActiveAlarms(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
return StreamAlarms(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||||
}
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Session-less central alarm feed. The stream opens with the current
|
||||||
|
/// active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
/// `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
/// Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
/// fan out from the single monitor without opening a worker session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="options">The options for the call.</param>
|
||||||
|
/// <returns>The call object.</returns>
|
||||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
public virtual grpc::AsyncServerStreamingCall<global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot> QueryActiveAlarms(global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest request, grpc::CallOptions options)
|
public virtual grpc::AsyncServerStreamingCall<global::MxGateway.Contracts.Proto.AlarmFeedMessage> StreamAlarms(global::MxGateway.Contracts.Proto.StreamAlarmsRequest request, grpc::CallOptions options)
|
||||||
{
|
{
|
||||||
return CallInvoker.AsyncServerStreamingCall(__Method_QueryActiveAlarms, null, options, request);
|
return CallInvoker.AsyncServerStreamingCall(__Method_StreamAlarms, null, options, request);
|
||||||
}
|
}
|
||||||
/// <summary>Creates a new instance of client from given <c>ClientBaseConfiguration</c>.</summary>
|
/// <summary>Creates a new instance of client from given <c>ClientBaseConfiguration</c>.</summary>
|
||||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
@@ -315,7 +348,7 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
.AddMethod(__Method_Invoke, serviceImpl.Invoke)
|
.AddMethod(__Method_Invoke, serviceImpl.Invoke)
|
||||||
.AddMethod(__Method_StreamEvents, serviceImpl.StreamEvents)
|
.AddMethod(__Method_StreamEvents, serviceImpl.StreamEvents)
|
||||||
.AddMethod(__Method_AcknowledgeAlarm, serviceImpl.AcknowledgeAlarm)
|
.AddMethod(__Method_AcknowledgeAlarm, serviceImpl.AcknowledgeAlarm)
|
||||||
.AddMethod(__Method_QueryActiveAlarms, serviceImpl.QueryActiveAlarms).Build();
|
.AddMethod(__Method_StreamAlarms, serviceImpl.StreamAlarms).Build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Register service method with a service binder with or without implementation. Useful when customizing the service binding logic.
|
/// <summary>Register service method with a service binder with or without implementation. Useful when customizing the service binding logic.
|
||||||
@@ -330,7 +363,7 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
serviceBinder.AddMethod(__Method_Invoke, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::MxGateway.Contracts.Proto.MxCommandRequest, global::MxGateway.Contracts.Proto.MxCommandReply>(serviceImpl.Invoke));
|
serviceBinder.AddMethod(__Method_Invoke, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::MxGateway.Contracts.Proto.MxCommandRequest, global::MxGateway.Contracts.Proto.MxCommandReply>(serviceImpl.Invoke));
|
||||||
serviceBinder.AddMethod(__Method_StreamEvents, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod<global::MxGateway.Contracts.Proto.StreamEventsRequest, global::MxGateway.Contracts.Proto.MxEvent>(serviceImpl.StreamEvents));
|
serviceBinder.AddMethod(__Method_StreamEvents, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod<global::MxGateway.Contracts.Proto.StreamEventsRequest, global::MxGateway.Contracts.Proto.MxEvent>(serviceImpl.StreamEvents));
|
||||||
serviceBinder.AddMethod(__Method_AcknowledgeAlarm, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest, global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply>(serviceImpl.AcknowledgeAlarm));
|
serviceBinder.AddMethod(__Method_AcknowledgeAlarm, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest, global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply>(serviceImpl.AcknowledgeAlarm));
|
||||||
serviceBinder.AddMethod(__Method_QueryActiveAlarms, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod<global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest, global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot>(serviceImpl.QueryActiveAlarms));
|
serviceBinder.AddMethod(__Method_StreamAlarms, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod<global::MxGateway.Contracts.Proto.StreamAlarmsRequest, global::MxGateway.Contracts.Proto.AlarmFeedMessage>(serviceImpl.StreamAlarms));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import "google/protobuf/timestamp.proto";
|
|||||||
// additively only. Never renumber or repurpose an existing field number or
|
// additively only. Never renumber or repurpose an existing field number or
|
||||||
// enum value. When a field or enum value is removed, add a `reserved` range
|
// enum value. When a field or enum value is removed, add a `reserved` range
|
||||||
// (and `reserved` name) covering it in the same change so a future editor
|
// (and `reserved` name) covering it in the same change so a future editor
|
||||||
// cannot accidentally reuse the retired tag. There are no `reserved`
|
// cannot accidentally reuse the retired tag.
|
||||||
// declarations today because no field or enum value has ever been removed.
|
|
||||||
|
|
||||||
// Public client API for MXAccess sessions hosted by the gateway.
|
// Public client API for MXAccess sessions hosted by the gateway.
|
||||||
service MxAccessGateway {
|
service MxAccessGateway {
|
||||||
@@ -21,7 +20,12 @@ service MxAccessGateway {
|
|||||||
rpc Invoke(MxCommandRequest) returns (MxCommandReply);
|
rpc Invoke(MxCommandRequest) returns (MxCommandReply);
|
||||||
rpc StreamEvents(StreamEventsRequest) returns (stream MxEvent);
|
rpc StreamEvents(StreamEventsRequest) returns (stream MxEvent);
|
||||||
rpc AcknowledgeAlarm(AcknowledgeAlarmRequest) returns (AcknowledgeAlarmReply);
|
rpc AcknowledgeAlarm(AcknowledgeAlarmRequest) returns (AcknowledgeAlarmReply);
|
||||||
rpc QueryActiveAlarms(QueryActiveAlarmsRequest) returns (stream ActiveAlarmSnapshot);
|
// Session-less central alarm feed. The stream opens with the current
|
||||||
|
// active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
// `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
// Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
// fan out from the single monitor without opening a worker session.
|
||||||
|
rpc StreamAlarms(StreamAlarmsRequest) returns (stream AlarmFeedMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
message OpenSessionRequest {
|
message OpenSessionRequest {
|
||||||
@@ -785,7 +789,10 @@ enum AlarmConditionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message AcknowledgeAlarmRequest {
|
message AcknowledgeAlarmRequest {
|
||||||
string session_id = 1;
|
// Retired: acknowledgement is session-less — it routes to the gateway's
|
||||||
|
// central alarm monitor, not a client worker session.
|
||||||
|
reserved 1;
|
||||||
|
reserved "session_id";
|
||||||
string client_correlation_id = 2;
|
string client_correlation_id = 2;
|
||||||
// Fully-qualified alarm reference matching OnAlarmTransitionEvent.alarm_full_reference.
|
// Fully-qualified alarm reference matching OnAlarmTransitionEvent.alarm_full_reference.
|
||||||
string alarm_full_reference = 3;
|
string alarm_full_reference = 3;
|
||||||
@@ -797,7 +804,9 @@ message AcknowledgeAlarmRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message AcknowledgeAlarmReply {
|
message AcknowledgeAlarmReply {
|
||||||
string session_id = 1;
|
// Retired: see AcknowledgeAlarmRequest — acknowledgement is session-less.
|
||||||
|
reserved 1;
|
||||||
|
reserved "session_id";
|
||||||
string correlation_id = 2;
|
string correlation_id = 2;
|
||||||
ProtocolStatus protocol_status = 3;
|
ProtocolStatus protocol_status = 3;
|
||||||
// Native ack return code echoed from the worker. The worker carries the
|
// Native ack return code echoed from the worker. The worker carries the
|
||||||
@@ -816,12 +825,27 @@ message AcknowledgeAlarmReply {
|
|||||||
string diagnostic_message = 6;
|
string diagnostic_message = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message QueryActiveAlarmsRequest {
|
// Request to attach to the gateway's central alarm feed (StreamAlarms).
|
||||||
string session_id = 1;
|
message StreamAlarmsRequest {
|
||||||
string client_correlation_id = 2;
|
string client_correlation_id = 1;
|
||||||
// Optional alarm-reference prefix used to scope a partial ConditionRefresh
|
// Optional alarm-reference prefix scoping the feed to an equipment
|
||||||
// (e.g. equipment sub-tree). Empty means full refresh.
|
// sub-tree. Empty streams every active alarm.
|
||||||
string alarm_filter_prefix = 3;
|
string alarm_filter_prefix = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One message on the StreamAlarms feed. The stream opens with one
|
||||||
|
// `active_alarm` per currently-active alarm, then a single
|
||||||
|
// `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
message AlarmFeedMessage {
|
||||||
|
oneof payload {
|
||||||
|
// Part of the initial active-alarm snapshot (ConditionRefresh).
|
||||||
|
ActiveAlarmSnapshot active_alarm = 1;
|
||||||
|
// Sentinel: the initial snapshot is fully delivered and `transition`
|
||||||
|
// messages follow. Always true when present.
|
||||||
|
bool snapshot_complete = 2;
|
||||||
|
// A live alarm state change (raise / acknowledge / clear).
|
||||||
|
OnAlarmTransitionEvent transition = 3;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
message MxStatusProxy {
|
message MxStatusProxy {
|
||||||
|
|||||||
@@ -1084,7 +1084,11 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
mapper,
|
mapper,
|
||||||
eventStreamService,
|
eventStreamService,
|
||||||
_metrics,
|
_metrics,
|
||||||
_loggerFactory.CreateLogger<MxAccessGatewayService>());
|
_loggerFactory.CreateLogger<MxAccessGatewayService>(),
|
||||||
|
new MxGateway.Server.Alarms.GatewayAlarmMonitor(
|
||||||
|
sessionManager,
|
||||||
|
options,
|
||||||
|
_loggerFactory.CreateLogger<MxGateway.Server.Alarms.GatewayAlarmMonitor>()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace MxGateway.Server.Alarms;
|
||||||
|
|
||||||
|
/// <summary>Service-collection wiring for the gateway's central alarm monitor.</summary>
|
||||||
|
public static class AlarmsServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the always-on <see cref="GatewayAlarmMonitor"/> as both
|
||||||
|
/// the <see cref="IGatewayAlarmService"/> singleton and a hosted
|
||||||
|
/// service, so it starts with the gateway host and is shared by the
|
||||||
|
/// gRPC alarm surface and the dashboard.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">Service collection to register services in.</param>
|
||||||
|
/// <returns>The service collection for chaining.</returns>
|
||||||
|
public static IServiceCollection AddGatewayAlarms(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<GatewayAlarmMonitor>();
|
||||||
|
services.AddSingleton<IGatewayAlarmService>(provider => provider.GetRequiredService<GatewayAlarmMonitor>());
|
||||||
|
services.AddHostedService(provider => provider.GetRequiredService<GatewayAlarmMonitor>());
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,693 @@
|
|||||||
|
using System.Threading.Channels;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Alarms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The gateway's always-on alarm monitor and broker. It owns one
|
||||||
|
/// gateway-managed worker session dedicated to alarms, keeps an in-process
|
||||||
|
/// cache of the active-alarm set fed by that session's transition events
|
||||||
|
/// (reconciled periodically against the worker's snapshot), and fans the
|
||||||
|
/// feed out to any number of <see cref="StreamAsync"/> subscribers.
|
||||||
|
/// The session is re-opened transparently if the worker faults.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmService
|
||||||
|
{
|
||||||
|
private const string MonitorClientName = "gateway-alarm-monitor";
|
||||||
|
private const string BackendName = "Galaxy";
|
||||||
|
private const int SubscriberQueueCapacity = 2048;
|
||||||
|
private static readonly TimeSpan RestartBackoff = TimeSpan.FromSeconds(5);
|
||||||
|
private static readonly TimeSpan StartupGrace = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly AlarmsOptions _options;
|
||||||
|
private readonly ILogger<GatewayAlarmMonitor> _logger;
|
||||||
|
|
||||||
|
private readonly object _sync = new();
|
||||||
|
private readonly Dictionary<string, ActiveAlarmSnapshot> _alarms = new(StringComparer.Ordinal);
|
||||||
|
private readonly List<Subscriber> _subscribers = [];
|
||||||
|
|
||||||
|
private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled;
|
||||||
|
private volatile string? _lastError;
|
||||||
|
private GatewaySession? _session;
|
||||||
|
|
||||||
|
/// <summary>Initializes the gateway alarm monitor.</summary>
|
||||||
|
/// <param name="sessionManager">Gateway session manager.</param>
|
||||||
|
/// <param name="options">Gateway options carrying the alarm configuration.</param>
|
||||||
|
/// <param name="logger">Diagnostic logger.</param>
|
||||||
|
public GatewayAlarmMonitor(
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
IOptions<GatewayOptions> options,
|
||||||
|
ILogger<GatewayAlarmMonitor> logger)
|
||||||
|
{
|
||||||
|
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
|
||||||
|
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.Alarms;
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public GatewayAlarmMonitorState State => _state;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? LastError => _lastError;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int? WorkerProcessId
|
||||||
|
{
|
||||||
|
get { lock (_sync) { return _session?.WorkerProcessId; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
return _alarms.Values.Select(alarm => alarm.Clone()).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled)
|
||||||
|
{
|
||||||
|
_state = GatewayAlarmMonitorState.Disabled;
|
||||||
|
_logger.LogInformation("Gateway alarm monitor disabled (MxGateway:Alarms:Enabled is false).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string subscription = ResolveSubscription();
|
||||||
|
if (string.IsNullOrWhiteSpace(subscription))
|
||||||
|
{
|
||||||
|
_state = GatewayAlarmMonitorState.Faulted;
|
||||||
|
_lastError = "MxGateway:Alarms is enabled but no SubscriptionExpression / DefaultArea is configured.";
|
||||||
|
_logger.LogError("{Diagnostic}", _lastError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brief grace so worker-process launching and startup orphan cleanup
|
||||||
|
// settle before the monitor opens its own session.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(StartupGrace, stoppingToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RunMonitorAsync(subscription, stoppingToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_state = GatewayAlarmMonitorState.Faulted;
|
||||||
|
_lastError = exception.Message;
|
||||||
|
_logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Gateway alarm monitor lifecycle faulted; restarting in {Backoff}.",
|
||||||
|
RestartBackoff);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(RestartBackoff, stoppingToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = GatewayAlarmMonitorState.Disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One monitoring lifecycle: open a session, subscribe alarms, reconcile,
|
||||||
|
// then consume transition events until the session ends or is cancelled.
|
||||||
|
private async Task RunMonitorAsync(string subscription, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_state = GatewayAlarmMonitorState.Starting;
|
||||||
|
GatewaySession session = await _sessionManager.OpenSessionAsync(
|
||||||
|
new SessionOpenRequest(BackendName, MonitorClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null),
|
||||||
|
MonitorClientName,
|
||||||
|
stoppingToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
lock (_sync) { _session = session; }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SubscribeAlarmsAsync(session.SessionId, subscription, stoppingToken).ConfigureAwait(false);
|
||||||
|
await ReconcileAsync(session.SessionId, stoppingToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_state = GatewayAlarmMonitorState.Monitoring;
|
||||||
|
_lastError = null;
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Gateway alarm monitor active on {Subscription} (session {SessionId}, worker pid {WorkerPid}).",
|
||||||
|
subscription,
|
||||||
|
session.SessionId,
|
||||||
|
session.WorkerProcessId);
|
||||||
|
|
||||||
|
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||||
|
Task reconcileLoop = ReconcileLoopAsync(session.SessionId, linked.Token);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (WorkerEvent workerEvent in _sessionManager
|
||||||
|
.ReadEventsAsync(session.SessionId, linked.Token)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
MxEvent? mxEvent = workerEvent.Event;
|
||||||
|
if (mxEvent is { BodyCase: MxEvent.BodyOneofCase.OnAlarmTransition }
|
||||||
|
&& mxEvent.OnAlarmTransition is not null)
|
||||||
|
{
|
||||||
|
ApplyTransition(mxEvent.OnAlarmTransition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await linked.CancelAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await reconcileLoop.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Reconcile-loop teardown errors are not actionable here.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The event stream ended without cancellation — the worker session
|
||||||
|
// closed or faulted. Surface it so the supervisor loop restarts.
|
||||||
|
throw new InvalidOperationException("Alarm monitor worker event stream ended.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_sync) { _session = null; }
|
||||||
|
ClearCache();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _sessionManager.CloseSessionAsync(session.SessionId, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(exception, "Closing alarm monitor session {SessionId} failed.", session.SessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SubscribeAlarmsAsync(string sessionId, string subscription, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
WorkerCommandReply reply = await _sessionManager.InvokeAsync(
|
||||||
|
sessionId,
|
||||||
|
new WorkerCommand
|
||||||
|
{
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.SubscribeAlarms,
|
||||||
|
SubscribeAlarms = new SubscribeAlarmsCommand { SubscriptionExpression = subscription },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
ProtocolStatusCode? code = reply.Reply?.ProtocolStatus?.Code;
|
||||||
|
if (code != ProtocolStatusCode.Ok)
|
||||||
|
{
|
||||||
|
string diagnostic = reply.Reply?.DiagnosticMessage
|
||||||
|
?? reply.Reply?.ProtocolStatus?.Message
|
||||||
|
?? $"status {code}";
|
||||||
|
throw new InvalidOperationException($"Worker rejected SubscribeAlarms: {diagnostic}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReconcileLoopAsync(string sessionId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int seconds = Math.Max(5, _options.ReconcileIntervalSeconds);
|
||||||
|
using PeriodicTimer timer = new(TimeSpan.FromSeconds(seconds));
|
||||||
|
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ReconcileAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(exception, "Alarm reconcile pass failed; keeping the current cache.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReconcileAsync(string sessionId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
WorkerCommandReply reply = await _sessionManager.InvokeAsync(
|
||||||
|
sessionId,
|
||||||
|
new WorkerCommand
|
||||||
|
{
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.QueryActiveAlarms,
|
||||||
|
QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand { AlarmFilterPrefix = string.Empty },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (reply.Reply?.ProtocolStatus?.Code != ProtocolStatusCode.Ok)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryActiveAlarmsReplyPayload? payload = reply.Reply.QueryActiveAlarms;
|
||||||
|
if (payload is not null)
|
||||||
|
{
|
||||||
|
ApplyReconcile(payload.Snapshots);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies a live transition to the cache and broadcasts it to subscribers.
|
||||||
|
private void ApplyTransition(OnAlarmTransitionEvent transition)
|
||||||
|
{
|
||||||
|
string reference = transition.AlarmFullReference ?? string.Empty;
|
||||||
|
if (reference.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (transition.TransitionKind == AlarmTransitionKind.Clear)
|
||||||
|
{
|
||||||
|
_alarms.Remove(reference);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_alarms[reference] = SnapshotFromTransition(transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
Broadcast(new AlarmFeedMessage { Transition = transition }, reference);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replaces the cache with the worker's authoritative snapshot, broadcasting
|
||||||
|
// a synthetic transition for any alarm the live stream missed.
|
||||||
|
private void ApplyReconcile(IEnumerable<ActiveAlarmSnapshot> snapshots)
|
||||||
|
{
|
||||||
|
Dictionary<string, ActiveAlarmSnapshot> next = new(StringComparer.Ordinal);
|
||||||
|
foreach (ActiveAlarmSnapshot snapshot in snapshots)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(snapshot.AlarmFullReference))
|
||||||
|
{
|
||||||
|
next[snapshot.AlarmFullReference] = snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
foreach (KeyValuePair<string, ActiveAlarmSnapshot> existing in _alarms)
|
||||||
|
{
|
||||||
|
if (!next.ContainsKey(existing.Key))
|
||||||
|
{
|
||||||
|
Broadcast(
|
||||||
|
new AlarmFeedMessage { Transition = TransitionFromSnapshot(existing.Value, AlarmTransitionKind.Clear) },
|
||||||
|
existing.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (KeyValuePair<string, ActiveAlarmSnapshot> incoming in next)
|
||||||
|
{
|
||||||
|
if (!_alarms.ContainsKey(incoming.Key))
|
||||||
|
{
|
||||||
|
Broadcast(
|
||||||
|
new AlarmFeedMessage { Transition = TransitionFromSnapshot(incoming.Value, AlarmTransitionKind.Raise) },
|
||||||
|
incoming.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_alarms.Clear();
|
||||||
|
foreach (KeyValuePair<string, ActiveAlarmSnapshot> incoming in next)
|
||||||
|
{
|
||||||
|
_alarms[incoming.Key] = incoming.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller holds _sync. Pushes a feed message to every matching subscriber;
|
||||||
|
// a subscriber that has fallen behind is completed with an error and dropped.
|
||||||
|
private void Broadcast(AlarmFeedMessage message, string reference)
|
||||||
|
{
|
||||||
|
for (int index = _subscribers.Count - 1; index >= 0; index--)
|
||||||
|
{
|
||||||
|
Subscriber subscriber = _subscribers[index];
|
||||||
|
if (!subscriber.Matches(reference))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscriber.Channel.Writer.TryWrite(message))
|
||||||
|
{
|
||||||
|
subscriber.Channel.Writer.TryComplete(new InvalidOperationException(
|
||||||
|
"Alarm feed subscriber fell behind and was dropped; reconnect to re-snapshot."));
|
||||||
|
_subscribers.RemoveAt(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearCache()
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
_alarms.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
|
||||||
|
string? alarmFilterPrefix,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string prefix = alarmFilterPrefix ?? string.Empty;
|
||||||
|
Channel<AlarmFeedMessage> channel = Channel.CreateBounded<AlarmFeedMessage>(
|
||||||
|
new BoundedChannelOptions(SubscriberQueueCapacity)
|
||||||
|
{
|
||||||
|
FullMode = BoundedChannelFullMode.Wait,
|
||||||
|
SingleReader = true,
|
||||||
|
SingleWriter = false,
|
||||||
|
});
|
||||||
|
Subscriber subscriber = new(channel, prefix);
|
||||||
|
|
||||||
|
ActiveAlarmSnapshot[] snapshot;
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
// Register before snapshotting under the same lock so no transition
|
||||||
|
// can slip between the snapshot and the live stream.
|
||||||
|
_subscribers.Add(subscriber);
|
||||||
|
snapshot = _alarms.Values
|
||||||
|
.Where(alarm => prefix.Length == 0
|
||||||
|
|| alarm.AlarmFullReference.StartsWith(prefix, StringComparison.Ordinal))
|
||||||
|
.Select(alarm => alarm.Clone())
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (ActiveAlarmSnapshot alarm in snapshot)
|
||||||
|
{
|
||||||
|
yield return new AlarmFeedMessage { ActiveAlarm = alarm };
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return new AlarmFeedMessage { SnapshotComplete = true };
|
||||||
|
|
||||||
|
await foreach (AlarmFeedMessage message in channel.Reader
|
||||||
|
.ReadAllAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
yield return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_sync) { _subscribers.Remove(subscriber); }
|
||||||
|
channel.Writer.TryComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<AcknowledgeAlarmReply> AcknowledgeAsync(
|
||||||
|
AcknowledgeAlarmRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
string? sessionId;
|
||||||
|
lock (_sync) { sessionId = _session?.SessionId; }
|
||||||
|
if (sessionId is null || _state != GatewayAlarmMonitorState.Monitoring)
|
||||||
|
{
|
||||||
|
return new AcknowledgeAlarmReply
|
||||||
|
{
|
||||||
|
CorrelationId = request.ClientCorrelationId,
|
||||||
|
ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.WorkerUnavailable,
|
||||||
|
Message = "Gateway alarm monitor is not currently active.",
|
||||||
|
},
|
||||||
|
DiagnosticMessage = _lastError ?? "Alarm monitor is not running.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
MxCommand? command = BuildAcknowledgeCommand(request, out string? parseError);
|
||||||
|
if (command is null)
|
||||||
|
{
|
||||||
|
return new AcknowledgeAlarmReply
|
||||||
|
{
|
||||||
|
CorrelationId = request.ClientCorrelationId,
|
||||||
|
ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.InvalidRequest,
|
||||||
|
Message = parseError ?? "Invalid acknowledge request.",
|
||||||
|
},
|
||||||
|
DiagnosticMessage = parseError ?? "Invalid acknowledge request.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkerCommandReply workerReply = await _sessionManager
|
||||||
|
.InvokeAsync(sessionId, new WorkerCommand { Command = command }, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
MxCommandReply mxReply = workerReply.Reply ?? new MxCommandReply
|
||||||
|
{
|
||||||
|
ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.ProtocolViolation,
|
||||||
|
Message = "Worker reply did not include an MxCommandReply.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
AcknowledgeAlarmReply reply = new()
|
||||||
|
{
|
||||||
|
CorrelationId = request.ClientCorrelationId,
|
||||||
|
ProtocolStatus = mxReply.ProtocolStatus ?? new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
DiagnosticMessage = mxReply.DiagnosticMessage ?? string.Empty,
|
||||||
|
};
|
||||||
|
if (mxReply.HasHresult)
|
||||||
|
{
|
||||||
|
reply.Hresult = mxReply.Hresult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveSubscription()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.SubscriptionExpression))
|
||||||
|
{
|
||||||
|
return _options.SubscriptionExpression;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.DefaultArea))
|
||||||
|
{
|
||||||
|
return $@"\\{Environment.MachineName}\Galaxy!{_options.DefaultArea}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError)
|
||||||
|
{
|
||||||
|
parseError = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(request.AlarmFullReference))
|
||||||
|
{
|
||||||
|
parseError = "alarm_full_reference is required.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string comment = request.Comment ?? string.Empty;
|
||||||
|
string operatorUser = request.OperatorUser ?? string.Empty;
|
||||||
|
|
||||||
|
if (Guid.TryParse(request.AlarmFullReference, out Guid guid))
|
||||||
|
{
|
||||||
|
return new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AcknowledgeAlarm,
|
||||||
|
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
|
||||||
|
{
|
||||||
|
AlarmGuid = guid.ToString(),
|
||||||
|
Comment = comment,
|
||||||
|
OperatorUser = operatorUser,
|
||||||
|
OperatorNode = string.Empty,
|
||||||
|
OperatorDomain = string.Empty,
|
||||||
|
OperatorFullName = string.Empty,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryParseAlarmReference(request.AlarmFullReference, out string provider, out string group, out string alarm))
|
||||||
|
{
|
||||||
|
return new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AcknowledgeAlarmByName,
|
||||||
|
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
|
||||||
|
{
|
||||||
|
AlarmName = alarm,
|
||||||
|
ProviderName = provider,
|
||||||
|
GroupName = group,
|
||||||
|
Comment = comment,
|
||||||
|
OperatorUser = operatorUser,
|
||||||
|
OperatorNode = string.Empty,
|
||||||
|
OperatorDomain = string.Empty,
|
||||||
|
OperatorFullName = string.Empty,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
parseError = "alarm_full_reference must be a canonical GUID or 'Provider!Group.Tag' format.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses an alarm reference of the form <c>Provider!Group.Tag</c>: the
|
||||||
|
/// first <c>!</c> splits provider from <c>Group.Tag</c>; the first
|
||||||
|
/// <c>.</c> after the <c>!</c> splits group from tag.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="reference">The full alarm reference.</param>
|
||||||
|
/// <param name="providerName">The parsed provider.</param>
|
||||||
|
/// <param name="groupName">The parsed group/area.</param>
|
||||||
|
/// <param name="alarmName">The parsed tag/alarm name.</param>
|
||||||
|
/// <returns>true on a well-formed reference; otherwise false.</returns>
|
||||||
|
public static bool TryParseAlarmReference(
|
||||||
|
string? reference,
|
||||||
|
out string providerName,
|
||||||
|
out string groupName,
|
||||||
|
out string alarmName)
|
||||||
|
{
|
||||||
|
providerName = string.Empty;
|
||||||
|
groupName = string.Empty;
|
||||||
|
alarmName = string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(reference))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int bang = reference!.IndexOf('!', StringComparison.Ordinal);
|
||||||
|
if (bang <= 0 || bang == reference.Length - 1)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string left = reference[..bang];
|
||||||
|
string right = reference[(bang + 1)..];
|
||||||
|
int dot = right.IndexOf('.', StringComparison.Ordinal);
|
||||||
|
if (dot <= 0 || dot == right.Length - 1)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
providerName = left;
|
||||||
|
groupName = right[..dot];
|
||||||
|
alarmName = right[(dot + 1)..];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ActiveAlarmSnapshot SnapshotFromTransition(OnAlarmTransitionEvent transition)
|
||||||
|
{
|
||||||
|
ActiveAlarmSnapshot snapshot = new()
|
||||||
|
{
|
||||||
|
AlarmFullReference = transition.AlarmFullReference,
|
||||||
|
SourceObjectReference = transition.SourceObjectReference,
|
||||||
|
AlarmTypeName = transition.AlarmTypeName,
|
||||||
|
Severity = transition.Severity,
|
||||||
|
CurrentState = transition.TransitionKind == AlarmTransitionKind.Acknowledge
|
||||||
|
? AlarmConditionState.ActiveAcked
|
||||||
|
: AlarmConditionState.Active,
|
||||||
|
Category = transition.Category,
|
||||||
|
Description = transition.Description,
|
||||||
|
OperatorUser = transition.OperatorUser,
|
||||||
|
OperatorComment = transition.OperatorComment,
|
||||||
|
};
|
||||||
|
if (transition.OriginalRaiseTimestamp is not null)
|
||||||
|
{
|
||||||
|
snapshot.OriginalRaiseTimestamp = transition.OriginalRaiseTimestamp;
|
||||||
|
}
|
||||||
|
if (transition.TransitionTimestamp is not null)
|
||||||
|
{
|
||||||
|
snapshot.LastTransitionTimestamp = transition.TransitionTimestamp;
|
||||||
|
}
|
||||||
|
if (transition.CurrentValue is not null)
|
||||||
|
{
|
||||||
|
snapshot.CurrentValue = transition.CurrentValue;
|
||||||
|
}
|
||||||
|
if (transition.LimitValue is not null)
|
||||||
|
{
|
||||||
|
snapshot.LimitValue = transition.LimitValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OnAlarmTransitionEvent TransitionFromSnapshot(
|
||||||
|
ActiveAlarmSnapshot snapshot,
|
||||||
|
AlarmTransitionKind kind)
|
||||||
|
{
|
||||||
|
OnAlarmTransitionEvent transition = new()
|
||||||
|
{
|
||||||
|
AlarmFullReference = snapshot.AlarmFullReference,
|
||||||
|
SourceObjectReference = snapshot.SourceObjectReference,
|
||||||
|
AlarmTypeName = snapshot.AlarmTypeName,
|
||||||
|
TransitionKind = kind,
|
||||||
|
Severity = snapshot.Severity,
|
||||||
|
Category = snapshot.Category,
|
||||||
|
Description = snapshot.Description,
|
||||||
|
OperatorUser = snapshot.OperatorUser,
|
||||||
|
OperatorComment = snapshot.OperatorComment,
|
||||||
|
};
|
||||||
|
if (snapshot.OriginalRaiseTimestamp is not null)
|
||||||
|
{
|
||||||
|
transition.OriginalRaiseTimestamp = snapshot.OriginalRaiseTimestamp;
|
||||||
|
}
|
||||||
|
if (snapshot.LastTransitionTimestamp is not null)
|
||||||
|
{
|
||||||
|
transition.TransitionTimestamp = snapshot.LastTransitionTimestamp;
|
||||||
|
}
|
||||||
|
if (snapshot.CurrentValue is not null)
|
||||||
|
{
|
||||||
|
transition.CurrentValue = snapshot.CurrentValue;
|
||||||
|
}
|
||||||
|
if (snapshot.LimitValue is not null)
|
||||||
|
{
|
||||||
|
transition.LimitValue = snapshot.LimitValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Subscriber(Channel<AlarmFeedMessage> channel, string prefix)
|
||||||
|
{
|
||||||
|
public Channel<AlarmFeedMessage> Channel { get; } = channel;
|
||||||
|
|
||||||
|
public bool Matches(string reference)
|
||||||
|
{
|
||||||
|
return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Alarms;
|
||||||
|
|
||||||
|
/// <summary>Lifecycle state of the gateway's central alarm monitor.</summary>
|
||||||
|
public enum GatewayAlarmMonitorState
|
||||||
|
{
|
||||||
|
/// <summary>Alarm monitoring is switched off (<c>MxGateway:Alarms:Enabled</c> is false).</summary>
|
||||||
|
Disabled,
|
||||||
|
|
||||||
|
/// <summary>The monitor is opening or re-opening its worker session.</summary>
|
||||||
|
Starting,
|
||||||
|
|
||||||
|
/// <summary>The monitor is connected and tracking the active-alarm set.</summary>
|
||||||
|
Monitoring,
|
||||||
|
|
||||||
|
/// <summary>The monitor's last lifecycle attempt failed; a restart is pending.</summary>
|
||||||
|
Faulted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The gateway's always-on alarm broker. A single gateway-owned worker
|
||||||
|
/// session monitors the AVEVA alarm provider; this service caches the
|
||||||
|
/// current active-alarm set and fans it out to any number of clients —
|
||||||
|
/// no client needs to open its own worker session to see alarms.
|
||||||
|
/// </summary>
|
||||||
|
public interface IGatewayAlarmService
|
||||||
|
{
|
||||||
|
/// <summary>Current monitor lifecycle state.</summary>
|
||||||
|
GatewayAlarmMonitorState State { get; }
|
||||||
|
|
||||||
|
/// <summary>Diagnostic message from the most recent fault, or null.</summary>
|
||||||
|
string? LastError { get; }
|
||||||
|
|
||||||
|
/// <summary>Process id of the worker backing the monitor, when one is attached.</summary>
|
||||||
|
int? WorkerProcessId { get; }
|
||||||
|
|
||||||
|
/// <summary>A point-in-time copy of the current active-alarm set.</summary>
|
||||||
|
IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attaches to the central alarm feed. The returned stream yields one
|
||||||
|
/// <see cref="AlarmFeedMessage"/> per currently-active alarm, then a
|
||||||
|
/// single <c>snapshot_complete</c> sentinel, then a <c>transition</c>
|
||||||
|
/// for every subsequent change.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="alarmFilterPrefix">Optional alarm-reference prefix scoping the feed.</param>
|
||||||
|
/// <param name="cancellationToken">Token that ends the subscription.</param>
|
||||||
|
IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
|
||||||
|
string? alarmFilterPrefix,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Acknowledges an alarm through the monitor's worker session. Never
|
||||||
|
/// throws — transport and monitor-state failures surface in the
|
||||||
|
/// reply's <see cref="AcknowledgeAlarmReply.ProtocolStatus"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The acknowledge request.</param>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the call.</param>
|
||||||
|
Task<AcknowledgeAlarmReply> AcknowledgeAsync(
|
||||||
|
AcknowledgeAlarmRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -1,32 +1,32 @@
|
|||||||
namespace MxGateway.Server.Configuration;
|
namespace MxGateway.Server.Configuration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-gateway alarm-subsystem configuration. Drives the auto-subscribe
|
/// Configuration for the gateway's always-on central alarm monitor
|
||||||
/// hook in <see cref="Sessions.SessionManager"/>: when
|
/// (<see cref="Alarms.GatewayAlarmMonitor"/>). When <see cref="Enabled"/>
|
||||||
/// <see cref="Enabled"/> is true and a session reaches Ready, the
|
/// is true the gateway opens one gateway-owned worker session dedicated to
|
||||||
/// manager issues a <c>SubscribeAlarmsCommand</c> to the worker with
|
/// alarms, caches the active-alarm set, and fans it out to every client
|
||||||
/// the configured <see cref="SubscriptionExpression"/>.
|
/// through the <c>StreamAlarms</c> RPC — no client opens its own session
|
||||||
|
/// to see alarms.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Defaults preserve current behaviour (alarms disabled). Operators
|
/// Defaults preserve current behaviour (alarm monitoring disabled).
|
||||||
/// opt in by setting <c>MxGateway:Alarms:Enabled = true</c> and
|
/// Operators opt in by setting <c>MxGateway:Alarms:Enabled = true</c> and
|
||||||
/// supplying a canonical
|
/// supplying a canonical <c>\\<machine>\Galaxy!<area></c>
|
||||||
/// <c>\\<machine>\Galaxy!<area></c> subscription
|
/// subscription expression. The literal "Galaxy" provider is correct
|
||||||
/// expression. The literal "Galaxy" provider is correct regardless of
|
/// regardless of the configured Galaxy database name (the wnwrap consumer
|
||||||
/// the configured Galaxy database name (the wnwrap consumer doesn't
|
/// does not accept the database name as the provider).
|
||||||
/// accept the database name as the provider).
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class AlarmsOptions
|
public sealed class AlarmsOptions
|
||||||
{
|
{
|
||||||
/// <summary>Gate the auto-subscribe hook on session open. Default false.</summary>
|
/// <summary>Gate the gateway's always-on central alarm monitor. Default false.</summary>
|
||||||
public bool Enabled { get; init; }
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// AVEVA alarm-subscription expression. When empty and
|
/// AVEVA alarm-subscription expression the monitor subscribes on
|
||||||
/// <see cref="Enabled"/> is true, the gateway falls back to
|
/// startup. When empty and <see cref="Enabled"/> is true, the gateway
|
||||||
/// <c>\\$(MachineName)\Galaxy!$(DefaultArea)</c> if
|
/// falls back to <c>\\$(MachineName)\Galaxy!$(DefaultArea)</c> if
|
||||||
/// <see cref="DefaultArea"/> is set; otherwise the session open
|
/// <see cref="DefaultArea"/> is set; otherwise the monitor faults with
|
||||||
/// fails with a configuration diagnostic.
|
/// a configuration diagnostic.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string SubscriptionExpression { get; init; } = string.Empty;
|
public string SubscriptionExpression { get; init; } = string.Empty;
|
||||||
|
|
||||||
@@ -39,10 +39,10 @@ public sealed class AlarmsOptions
|
|||||||
public string DefaultArea { get; init; } = string.Empty;
|
public string DefaultArea { get; init; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// If true, an auto-subscribe failure faults the session. If false
|
/// How often the monitor reconciles its in-process alarm cache against
|
||||||
/// (default), the failure is logged and the session remains Ready —
|
/// the worker's authoritative active-alarm snapshot, catching any
|
||||||
/// alarm-side commands return "not subscribed" but data subscriptions
|
/// transitions the live poll-and-diff feed missed. Default 30 seconds;
|
||||||
/// work normally.
|
/// the monitor floors it at 5 seconds.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool RequireSubscribeOnOpen { get; init; }
|
public int ReconcileIntervalSeconds { get; init; } = 30;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,11 +236,10 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the alarm auto-subscribe hook is enabled, the gateway needs either a
|
// When the central alarm monitor is enabled, it needs either a canonical
|
||||||
// canonical SubscriptionExpression or a DefaultArea to compose one from. Both
|
// SubscriptionExpression or a DefaultArea to compose one from. Validating
|
||||||
// empty is the configuration mistake SessionManager.TryAutoSubscribeAlarmsAsync
|
// it at startup makes the misconfiguration fail-fast at boot, in line
|
||||||
// currently surfaces per-session — pulling it up to startup validation makes
|
// with every other section.
|
||||||
// the misconfiguration fail-fast at boot, in line with every other section.
|
|
||||||
if (string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
if (string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
||||||
&& string.IsNullOrWhiteSpace(options.DefaultArea))
|
&& string.IsNullOrWhiteSpace(options.DefaultArea))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Alarms;
|
||||||
using MxGateway.Server.Sessions;
|
using MxGateway.Server.Sessions;
|
||||||
|
|
||||||
namespace MxGateway.Server.Dashboard;
|
namespace MxGateway.Server.Dashboard;
|
||||||
@@ -17,7 +18,7 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
|
|||||||
private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5);
|
private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
private readonly IAlarmRpcDispatcher _alarmDispatcher;
|
private readonly IGatewayAlarmService _alarmService;
|
||||||
private readonly ILogger<DashboardLiveDataService> _logger;
|
private readonly ILogger<DashboardLiveDataService> _logger;
|
||||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
private readonly HashSet<string> _subscribed = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _subscribed = new(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -28,15 +29,15 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
|
|||||||
|
|
||||||
/// <summary>Initializes the live-data service.</summary>
|
/// <summary>Initializes the live-data service.</summary>
|
||||||
/// <param name="sessionManager">Gateway session manager.</param>
|
/// <param name="sessionManager">Gateway session manager.</param>
|
||||||
/// <param name="alarmDispatcher">Active-alarm query dispatcher.</param>
|
/// <param name="alarmService">Gateway central alarm service.</param>
|
||||||
/// <param name="logger">Diagnostic logger.</param>
|
/// <param name="logger">Diagnostic logger.</param>
|
||||||
public DashboardLiveDataService(
|
public DashboardLiveDataService(
|
||||||
ISessionManager sessionManager,
|
ISessionManager sessionManager,
|
||||||
IAlarmRpcDispatcher alarmDispatcher,
|
IGatewayAlarmService alarmService,
|
||||||
ILogger<DashboardLiveDataService> logger)
|
ILogger<DashboardLiveDataService> logger)
|
||||||
{
|
{
|
||||||
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
|
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
|
||||||
_alarmDispatcher = alarmDispatcher ?? throw new ArgumentNullException(nameof(alarmDispatcher));
|
_alarmService = alarmService ?? throw new ArgumentNullException(nameof(alarmService));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,39 +91,20 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<DashboardAlarmQueryResult> QueryAlarmsAsync(CancellationToken cancellationToken)
|
public Task<DashboardAlarmQueryResult> QueryAlarmsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
// Alarms come from the gateway's always-on central monitor; the
|
||||||
try
|
// dashboard reads its in-process cache directly — no session needed.
|
||||||
{
|
DashboardActiveAlarm[] alarms = _alarmService.CurrentAlarms
|
||||||
(GatewaySession session, _) = await EnsureReadyAsync(cancellationToken).ConfigureAwait(false);
|
.Select(DashboardActiveAlarm.FromSnapshot)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
QueryActiveAlarmsRequest request = new()
|
string? error = _alarmService.State is GatewayAlarmMonitorState.Monitoring
|
||||||
{
|
or GatewayAlarmMonitorState.Disabled
|
||||||
SessionId = session.SessionId,
|
? null
|
||||||
ClientCorrelationId = Guid.NewGuid().ToString("N"),
|
: _alarmService.LastError ?? $"Alarm monitor is {_alarmService.State}.";
|
||||||
};
|
|
||||||
|
|
||||||
List<DashboardActiveAlarm> alarms = [];
|
return Task.FromResult(new DashboardAlarmQueryResult(alarms, error, _alarmService.WorkerProcessId));
|
||||||
await foreach (ActiveAlarmSnapshot snapshot in _alarmDispatcher
|
|
||||||
.QueryActiveAlarmsAsync(request, cancellationToken)
|
|
||||||
.ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
alarms.Add(DashboardActiveAlarm.FromSnapshot(snapshot));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new DashboardAlarmQueryResult(alarms, null, session.WorkerProcessId);
|
|
||||||
}
|
|
||||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
|
||||||
{
|
|
||||||
InvalidateSession();
|
|
||||||
_logger.LogWarning(exception, "Dashboard alarm query failed; the dashboard session will be re-opened.");
|
|
||||||
return new DashboardAlarmQueryResult([], exception.Message, null);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_gate.Release();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns a Ready session + its Register server handle, opening a fresh
|
// Returns a Ready session + its Register server handle, opening a fresh
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ namespace MxGateway.Server.Dashboard;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DashboardMxValueFormatter
|
public static class DashboardMxValueFormatter
|
||||||
{
|
{
|
||||||
|
/// <summary>Maximum array elements rendered inline before the value is truncated.</summary>
|
||||||
|
private const int MaxArrayElements = 24;
|
||||||
|
|
||||||
/// <summary>Formats the value payload of an <see cref="MxValue"/>.</summary>
|
/// <summary>Formats the value payload of an <see cref="MxValue"/>.</summary>
|
||||||
/// <param name="value">The value to format; may be null.</param>
|
/// <param name="value">The value to format; may be null.</param>
|
||||||
/// <returns>A display string — never null.</returns>
|
/// <returns>A display string — never null.</returns>
|
||||||
@@ -37,7 +40,7 @@ public static class DashboardMxValueFormatter
|
|||||||
.ToDateTimeOffset()
|
.ToDateTimeOffset()
|
||||||
.UtcDateTime
|
.UtcDateTime
|
||||||
.ToString("yyyy-MM-dd HH:mm:ss.fff 'UTC'", CultureInfo.InvariantCulture),
|
.ToString("yyyy-MM-dd HH:mm:ss.fff 'UTC'", CultureInfo.InvariantCulture),
|
||||||
MxValue.KindOneofCase.ArrayValue => "(array)",
|
MxValue.KindOneofCase.ArrayValue => FormatArray(value.ArrayValue),
|
||||||
MxValue.KindOneofCase.RawValue => $"({value.RawValue.Length} bytes)",
|
MxValue.KindOneofCase.RawValue => $"({value.RawValue.Length} bytes)",
|
||||||
_ => "-",
|
_ => "-",
|
||||||
};
|
};
|
||||||
@@ -45,9 +48,63 @@ public static class DashboardMxValueFormatter
|
|||||||
|
|
||||||
/// <summary>Formats the MXAccess data type of an <see cref="MxValue"/>.</summary>
|
/// <summary>Formats the MXAccess data type of an <see cref="MxValue"/>.</summary>
|
||||||
/// <param name="value">The value whose data type to describe; may be null.</param>
|
/// <param name="value">The value whose data type to describe; may be null.</param>
|
||||||
/// <returns>The data-type name — never null.</returns>
|
/// <returns>The data-type name — never null. Arrays render as <c>Element[dims]</c>.</returns>
|
||||||
public static string FormatDataType(MxValue? value)
|
public static string FormatDataType(MxValue? value)
|
||||||
{
|
{
|
||||||
return value is null ? "-" : value.DataType.ToString();
|
if (value is null)
|
||||||
|
{
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
// A scalar carries its type in MxValue.DataType, but an array leaves
|
||||||
|
// that Unspecified and carries the element type on the MxArray itself.
|
||||||
|
return value.KindCase == MxValue.KindOneofCase.ArrayValue
|
||||||
|
? FormatArrayDataType(value.ArrayValue)
|
||||||
|
: value.DataType.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatArrayDataType(MxArray array)
|
||||||
|
{
|
||||||
|
string dimensions = array.Dimensions.Count > 0
|
||||||
|
? string.Join(",", array.Dimensions)
|
||||||
|
: string.Empty;
|
||||||
|
return $"{array.ElementDataType}[{dimensions}]";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatArray(MxArray array)
|
||||||
|
{
|
||||||
|
IReadOnlyList<string> elements = array.ValuesCase switch
|
||||||
|
{
|
||||||
|
MxArray.ValuesOneofCase.BoolValues =>
|
||||||
|
array.BoolValues.Values.Select(item => item ? "true" : "false").ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.Int32Values =>
|
||||||
|
array.Int32Values.Values.Select(item => item.ToString(CultureInfo.InvariantCulture)).ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.Int64Values =>
|
||||||
|
array.Int64Values.Values.Select(item => item.ToString(CultureInfo.InvariantCulture)).ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.FloatValues =>
|
||||||
|
array.FloatValues.Values.Select(item => item.ToString("G7", CultureInfo.InvariantCulture)).ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.DoubleValues =>
|
||||||
|
array.DoubleValues.Values.Select(item => item.ToString("G15", CultureInfo.InvariantCulture)).ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.StringValues =>
|
||||||
|
array.StringValues.Values.Select(item => $"\"{item}\"").ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.TimestampValues =>
|
||||||
|
array.TimestampValues.Values
|
||||||
|
.Select(item => item.ToDateTimeOffset().UtcDateTime
|
||||||
|
.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture))
|
||||||
|
.ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.RawValues =>
|
||||||
|
array.RawValues.Values.Select(item => $"({item.Length} bytes)").ToArray(),
|
||||||
|
_ => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (elements.Count == 0)
|
||||||
|
{
|
||||||
|
return "[]";
|
||||||
|
}
|
||||||
|
|
||||||
|
string body = string.Join(", ", elements.Take(MaxArrayElements));
|
||||||
|
return elements.Count > MaxArrayElements
|
||||||
|
? $"[{body}, … {elements.Count} total]"
|
||||||
|
: $"[{body}]";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ namespace MxGateway.Server.Galaxy;
|
|||||||
/// refresh and reused across requests. Refreshes are deploy-time gated: every tick
|
/// refresh and reused across requests. Refreshes are deploy-time gated: every tick
|
||||||
/// queries <c>galaxy.time_of_last_deploy</c> (cheap), and the heavy hierarchy +
|
/// queries <c>galaxy.time_of_last_deploy</c> (cheap), and the heavy hierarchy +
|
||||||
/// attributes rowsets are pulled only when that timestamp has advanced.
|
/// attributes rowsets are pulled only when that timestamp has advanced.
|
||||||
|
/// Each successful heavy refresh is persisted to disk through
|
||||||
|
/// <see cref="IGalaxyHierarchySnapshotStore"/>; the first refresh restores that
|
||||||
|
/// snapshot (as <see cref="GalaxyCacheStatus.Stale"/>) so clients can browse
|
||||||
|
/// last-known data when the Galaxy database is unreachable on a cold start.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
||||||
{
|
{
|
||||||
@@ -19,27 +23,35 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
|||||||
|
|
||||||
private readonly IGalaxyRepository _repository;
|
private readonly IGalaxyRepository _repository;
|
||||||
private readonly IGalaxyDeployNotifier _notifier;
|
private readonly IGalaxyDeployNotifier _notifier;
|
||||||
|
private readonly IGalaxyHierarchySnapshotStore? _snapshotStore;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly ILogger<GalaxyHierarchyCache>? _logger;
|
private readonly ILogger<GalaxyHierarchyCache>? _logger;
|
||||||
private readonly TaskCompletionSource _firstLoad = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
private readonly TaskCompletionSource _firstLoad = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
||||||
private GalaxyHierarchyCacheEntry _current = GalaxyHierarchyCacheEntry.Empty;
|
private GalaxyHierarchyCacheEntry _current = GalaxyHierarchyCacheEntry.Empty;
|
||||||
|
private bool _restoreAttempted;
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the <see cref="GalaxyHierarchyCache"/> class.</summary>
|
/// <summary>Initializes a new instance of the <see cref="GalaxyHierarchyCache"/> class.</summary>
|
||||||
/// <param name="repository">Galaxy Repository client for SQL queries.</param>
|
/// <param name="repository">Galaxy Repository client for SQL queries.</param>
|
||||||
/// <param name="notifier">Galaxy deploy event notifier.</param>
|
/// <param name="notifier">Galaxy deploy event notifier.</param>
|
||||||
/// <param name="timeProvider">Provider for current time; defaults to system time.</param>
|
/// <param name="timeProvider">Provider for current time; defaults to system time.</param>
|
||||||
/// <param name="logger">Optional logger for diagnostic output.</param>
|
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||||
|
/// <param name="snapshotStore">
|
||||||
|
/// Optional on-disk snapshot store. When supplied, the cache persists each
|
||||||
|
/// successful refresh and restores the last snapshot on first load.
|
||||||
|
/// </param>
|
||||||
public GalaxyHierarchyCache(
|
public GalaxyHierarchyCache(
|
||||||
IGalaxyRepository repository,
|
IGalaxyRepository repository,
|
||||||
IGalaxyDeployNotifier notifier,
|
IGalaxyDeployNotifier notifier,
|
||||||
TimeProvider? timeProvider = null,
|
TimeProvider? timeProvider = null,
|
||||||
ILogger<GalaxyHierarchyCache>? logger = null)
|
ILogger<GalaxyHierarchyCache>? logger = null,
|
||||||
|
IGalaxyHierarchySnapshotStore? snapshotStore = null)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_notifier = notifier;
|
_notifier = notifier;
|
||||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_snapshotStore = snapshotStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Gets the current Galaxy hierarchy cache entry with projected status.</summary>
|
/// <summary>Gets the current Galaxy hierarchy cache entry with projected status.</summary>
|
||||||
@@ -88,6 +100,15 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
|||||||
|
|
||||||
private async Task RefreshCoreAsync(CancellationToken cancellationToken)
|
private async Task RefreshCoreAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// First refresh only: seed the cache from the on-disk snapshot before
|
||||||
|
// querying SQL, so a cold start with an unreachable Galaxy database can
|
||||||
|
// still serve last-known browse data. Runs under the refresh gate.
|
||||||
|
if (!_restoreAttempted)
|
||||||
|
{
|
||||||
|
_restoreAttempted = true;
|
||||||
|
await TryRestoreFromDiskAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
GalaxyHierarchyCacheEntry previous = Volatile.Read(ref _current);
|
GalaxyHierarchyCacheEntry previous = Volatile.Read(ref _current);
|
||||||
DateTimeOffset queriedAt = _timeProvider.GetUtcNow();
|
DateTimeOffset queriedAt = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
@@ -130,41 +151,17 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
|||||||
|
|
||||||
List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
|
List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
|
||||||
List<GalaxyAttributeRow> attributes = attributesTask.Result;
|
List<GalaxyAttributeRow> attributes = attributesTask.Result;
|
||||||
IReadOnlyList<GalaxyObject> objects = BuildObjects(hierarchy, attributes);
|
|
||||||
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build(objects);
|
|
||||||
|
|
||||||
int areaCount = hierarchy.Count(row => row.IsArea);
|
long nextSequence = previous.Sequence + 1;
|
||||||
int historized = attributes.Count(row => row.IsHistorized);
|
GalaxyHierarchyCacheEntry next = BuildEntry(
|
||||||
int alarms = attributes.Count(row => row.IsAlarm);
|
|
||||||
DashboardGalaxySummary dashboardSummary = BuildDashboardSummary(
|
|
||||||
status: GalaxyCacheStatus.Healthy,
|
status: GalaxyCacheStatus.Healthy,
|
||||||
|
sequence: nextSequence,
|
||||||
lastQueriedAt: queriedAt,
|
lastQueriedAt: queriedAt,
|
||||||
lastSuccessAt: queriedAt,
|
lastSuccessAt: queriedAt,
|
||||||
lastDeployTime: deployTime,
|
lastDeployTime: deployTime,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
hierarchy: hierarchy,
|
hierarchy: hierarchy,
|
||||||
objectCount: hierarchy.Count,
|
attributes: attributes);
|
||||||
areaCount: areaCount,
|
|
||||||
attributeCount: attributes.Count,
|
|
||||||
historizedAttributeCount: historized,
|
|
||||||
alarmAttributeCount: alarms);
|
|
||||||
|
|
||||||
long nextSequence = previous.Sequence + 1;
|
|
||||||
GalaxyHierarchyCacheEntry next = new(
|
|
||||||
Status: GalaxyCacheStatus.Healthy,
|
|
||||||
Sequence: nextSequence,
|
|
||||||
LastQueriedAt: queriedAt,
|
|
||||||
LastSuccessAt: queriedAt,
|
|
||||||
LastDeployTime: deployTime,
|
|
||||||
LastError: null,
|
|
||||||
Objects: objects,
|
|
||||||
Index: index,
|
|
||||||
DashboardSummary: dashboardSummary,
|
|
||||||
ObjectCount: hierarchy.Count,
|
|
||||||
AreaCount: areaCount,
|
|
||||||
AttributeCount: attributes.Count,
|
|
||||||
HistorizedAttributeCount: historized,
|
|
||||||
AlarmAttributeCount: alarms);
|
|
||||||
|
|
||||||
Volatile.Write(ref _current, next);
|
Volatile.Write(ref _current, next);
|
||||||
_firstLoad.TrySetResult();
|
_firstLoad.TrySetResult();
|
||||||
@@ -175,6 +172,8 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
|||||||
TimeOfLastDeploy: deployTime,
|
TimeOfLastDeploy: deployTime,
|
||||||
ObjectCount: hierarchy.Count,
|
ObjectCount: hierarchy.Count,
|
||||||
AttributeCount: attributes.Count));
|
AttributeCount: attributes.Count));
|
||||||
|
|
||||||
|
await PersistSnapshotAsync(deployTime, queriedAt, hierarchy, attributes, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -205,6 +204,161 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Materializes a complete <see cref="GalaxyHierarchyCacheEntry"/> from raw
|
||||||
|
/// hierarchy and attribute rowsets. Shared by the live refresh path and the
|
||||||
|
/// on-disk restore path so both produce an identical object list, index, and
|
||||||
|
/// dashboard summary.
|
||||||
|
/// </summary>
|
||||||
|
private static GalaxyHierarchyCacheEntry BuildEntry(
|
||||||
|
GalaxyCacheStatus status,
|
||||||
|
long sequence,
|
||||||
|
DateTimeOffset? lastQueriedAt,
|
||||||
|
DateTimeOffset? lastSuccessAt,
|
||||||
|
DateTimeOffset? lastDeployTime,
|
||||||
|
string? lastError,
|
||||||
|
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||||
|
IReadOnlyList<GalaxyAttributeRow> attributes)
|
||||||
|
{
|
||||||
|
IReadOnlyList<GalaxyObject> objects = BuildObjects(hierarchy, attributes);
|
||||||
|
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build(objects);
|
||||||
|
|
||||||
|
int areaCount = hierarchy.Count(row => row.IsArea);
|
||||||
|
int historized = attributes.Count(row => row.IsHistorized);
|
||||||
|
int alarms = attributes.Count(row => row.IsAlarm);
|
||||||
|
DashboardGalaxySummary dashboardSummary = BuildDashboardSummary(
|
||||||
|
status: status,
|
||||||
|
lastQueriedAt: lastQueriedAt,
|
||||||
|
lastSuccessAt: lastSuccessAt,
|
||||||
|
lastDeployTime: lastDeployTime,
|
||||||
|
lastError: lastError,
|
||||||
|
hierarchy: hierarchy,
|
||||||
|
objectCount: hierarchy.Count,
|
||||||
|
areaCount: areaCount,
|
||||||
|
attributeCount: attributes.Count,
|
||||||
|
historizedAttributeCount: historized,
|
||||||
|
alarmAttributeCount: alarms);
|
||||||
|
|
||||||
|
return new GalaxyHierarchyCacheEntry(
|
||||||
|
Status: status,
|
||||||
|
Sequence: sequence,
|
||||||
|
LastQueriedAt: lastQueriedAt,
|
||||||
|
LastSuccessAt: lastSuccessAt,
|
||||||
|
LastDeployTime: lastDeployTime,
|
||||||
|
LastError: lastError,
|
||||||
|
Objects: objects,
|
||||||
|
Index: index,
|
||||||
|
DashboardSummary: dashboardSummary,
|
||||||
|
ObjectCount: hierarchy.Count,
|
||||||
|
AreaCount: areaCount,
|
||||||
|
AttributeCount: attributes.Count,
|
||||||
|
HistorizedAttributeCount: historized,
|
||||||
|
AlarmAttributeCount: alarms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds the cache from the on-disk snapshot when no live data has loaded yet.
|
||||||
|
/// The restored entry is marked <see cref="GalaxyCacheStatus.Stale"/> — it is
|
||||||
|
/// last-known data, not live. A later refresh that observes the same deploy
|
||||||
|
/// time promotes it to healthy; one that observes a newer deploy replaces it.
|
||||||
|
/// </summary>
|
||||||
|
private async Task TryRestoreFromDiskAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_snapshotStore is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Volatile.Read(ref _current).HasData)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GalaxyHierarchySnapshot? snapshot;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
snapshot = await _snapshotStore.TryLoadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(exception, "Failed to restore the Galaxy hierarchy from the on-disk snapshot.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long sequence = Volatile.Read(ref _current).Sequence + 1;
|
||||||
|
GalaxyHierarchyCacheEntry restored = BuildEntry(
|
||||||
|
status: GalaxyCacheStatus.Stale,
|
||||||
|
sequence: sequence,
|
||||||
|
lastQueriedAt: snapshot.SavedAt,
|
||||||
|
lastSuccessAt: snapshot.SavedAt,
|
||||||
|
lastDeployTime: snapshot.LastDeployTime,
|
||||||
|
lastError: null,
|
||||||
|
hierarchy: snapshot.Hierarchy,
|
||||||
|
attributes: snapshot.Attributes);
|
||||||
|
Volatile.Write(ref _current, restored);
|
||||||
|
|
||||||
|
// Restored data is a valid completed first load: unblock callers waiting on
|
||||||
|
// the bootstrap gate immediately, rather than making them wait out the full
|
||||||
|
// wait budget for a live query that — when the database is unreachable, the
|
||||||
|
// scenario this restore exists for — may not return for seconds.
|
||||||
|
_firstLoad.TrySetResult();
|
||||||
|
|
||||||
|
_notifier.Publish(new GalaxyDeployEventInfo(
|
||||||
|
Sequence: sequence,
|
||||||
|
ObservedAt: _timeProvider.GetUtcNow(),
|
||||||
|
TimeOfLastDeploy: snapshot.LastDeployTime,
|
||||||
|
ObjectCount: snapshot.Hierarchy.Count,
|
||||||
|
AttributeCount: snapshot.Attributes.Count));
|
||||||
|
|
||||||
|
_logger?.LogInformation(
|
||||||
|
"Restored Galaxy hierarchy from on-disk snapshot saved {SavedAt:o}: {ObjectCount} objects, {AttributeCount} attributes (status Stale until the Galaxy database confirms).",
|
||||||
|
snapshot.SavedAt,
|
||||||
|
snapshot.Hierarchy.Count,
|
||||||
|
snapshot.Attributes.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists a successful refresh to disk. Persistence failures are logged and
|
||||||
|
/// swallowed — a cache that cannot write its backup is still fully usable.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PersistSnapshotAsync(
|
||||||
|
DateTimeOffset? deployTime,
|
||||||
|
DateTimeOffset savedAt,
|
||||||
|
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||||
|
IReadOnlyList<GalaxyAttributeRow> attributes,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_snapshotStore is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _snapshotStore.SaveAsync(
|
||||||
|
new GalaxyHierarchySnapshot(deployTime, savedAt, hierarchy, attributes),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// The refresh was cancelled (gateway shutdown) before the write finished.
|
||||||
|
// That is not a persistence failure — do not log it as a warning.
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(exception, "Failed to persist the Galaxy hierarchy snapshot to disk.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<GalaxyObject> BuildObjects(
|
private static IReadOnlyList<GalaxyObject> BuildObjects(
|
||||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||||
IReadOnlyList<GalaxyAttributeRow> attributes)
|
IReadOnlyList<GalaxyAttributeRow> attributes)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace MxGateway.Server.Galaxy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A serializable point-in-time copy of the Galaxy Repository browse data.
|
||||||
|
/// Holds the raw hierarchy and attribute rowsets — not the materialized
|
||||||
|
/// protobuf objects — so the restore path runs the exact same
|
||||||
|
/// materialization as a live refresh. Persisted by
|
||||||
|
/// <see cref="IGalaxyHierarchySnapshotStore"/> after a successful refresh
|
||||||
|
/// and reloaded at startup when the Galaxy database is unreachable.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="LastDeployTime">
|
||||||
|
/// The <c>galaxy.time_of_last_deploy</c> the rowsets were pulled at, or
|
||||||
|
/// <see langword="null"/> when the Galaxy table reported no deploy. A later
|
||||||
|
/// live refresh that observes this same timestamp can promote the restored
|
||||||
|
/// entry to healthy without re-running the heavy queries.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="SavedAt">UTC wall-clock when the snapshot was written to disk.</param>
|
||||||
|
/// <param name="Hierarchy">The persisted object-hierarchy rowset.</param>
|
||||||
|
/// <param name="Attributes">The persisted attribute rowset.</param>
|
||||||
|
public sealed record GalaxyHierarchySnapshot(
|
||||||
|
DateTimeOffset? LastDeployTime,
|
||||||
|
DateTimeOffset SavedAt,
|
||||||
|
IReadOnlyList<GalaxyHierarchyRow> Hierarchy,
|
||||||
|
IReadOnlyList<GalaxyAttributeRow> Attributes);
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Galaxy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JSON-file implementation of <see cref="IGalaxyHierarchySnapshotStore"/>.
|
||||||
|
/// Writes the on-disk snapshot atomically (temp file + rename) so a crash
|
||||||
|
/// mid-write can never leave a torn file, and ignores files whose schema
|
||||||
|
/// version it does not recognize. When
|
||||||
|
/// <see cref="GalaxyRepositoryOptions.PersistSnapshot"/> is <see langword="false"/>
|
||||||
|
/// both operations are no-ops.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyHierarchySnapshotStore : IGalaxyHierarchySnapshotStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// On-disk format version. Bump this whenever the persisted shape changes
|
||||||
|
/// in a way an older or newer gateway cannot read; a mismatched file is
|
||||||
|
/// ignored rather than misparsed.
|
||||||
|
/// </summary>
|
||||||
|
private const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly string? _path;
|
||||||
|
private readonly TimeSpan _writeTimeout;
|
||||||
|
private readonly ILogger<GalaxyHierarchySnapshotStore>? _logger;
|
||||||
|
private readonly SemaphoreSlim _ioGate = new(1, 1);
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="GalaxyHierarchySnapshotStore"/> class.</summary>
|
||||||
|
/// <param name="options">Galaxy repository options carrying the snapshot path and enable flag.</param>
|
||||||
|
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||||
|
public GalaxyHierarchySnapshotStore(
|
||||||
|
IOptions<GalaxyRepositoryOptions> options,
|
||||||
|
ILogger<GalaxyHierarchySnapshotStore>? logger = null)
|
||||||
|
{
|
||||||
|
GalaxyRepositoryOptions value = options.Value;
|
||||||
|
_path = value.PersistSnapshot && !string.IsNullOrWhiteSpace(value.SnapshotCachePath)
|
||||||
|
? value.SnapshotCachePath
|
||||||
|
: null;
|
||||||
|
_writeTimeout = TimeSpan.FromSeconds(Math.Max(1, value.CommandTimeoutSeconds));
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
|
if (_path is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistedFile file = new(CurrentSchemaVersion, snapshot);
|
||||||
|
|
||||||
|
await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Bound the write so a stuck disk — e.g. a SnapshotCachePath on an
|
||||||
|
// unresponsive network share — cannot stall the caller. On the cache
|
||||||
|
// refresh path that would otherwise pin the whole refresh loop.
|
||||||
|
using CancellationTokenSource writeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
writeCts.CancelAfter(_writeTimeout);
|
||||||
|
|
||||||
|
string? directory = Path.GetDirectoryName(_path);
|
||||||
|
if (!string.IsNullOrEmpty(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
string tempPath = _path + ".tmp";
|
||||||
|
await using (FileStream stream = new(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||||
|
{
|
||||||
|
await JsonSerializer.SerializeAsync(stream, file, SerializerOptions, writeCts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Move(tempPath, _path, overwrite: true);
|
||||||
|
_logger?.LogDebug(
|
||||||
|
"Persisted Galaxy hierarchy snapshot to {Path} ({ObjectCount} objects, {AttributeCount} attributes).",
|
||||||
|
_path,
|
||||||
|
snapshot.Hierarchy.Count,
|
||||||
|
snapshot.Attributes.Count);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_ioGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_path is null || !File.Exists(_path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PersistedFile? file;
|
||||||
|
await using (FileStream stream = new(_path, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||||
|
{
|
||||||
|
file = await JsonSerializer.DeserializeAsync<PersistedFile>(
|
||||||
|
stream, SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file is null || file.SchemaVersion != CurrentSchemaVersion || file.Snapshot is null)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(
|
||||||
|
"Ignoring Galaxy hierarchy snapshot at {Path}: unrecognized or empty schema version.",
|
||||||
|
_path);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return file.Snapshot;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// A corrupt, truncated, locked, or access-denied snapshot file is an
|
||||||
|
// expected failure mode for a disk cache — honor the Try contract and
|
||||||
|
// return null rather than throwing.
|
||||||
|
_logger?.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Ignoring Galaxy hierarchy snapshot at {Path}: the file is unreadable or not valid JSON.",
|
||||||
|
_path);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_ioGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>On-disk envelope: a schema version plus the snapshot payload.</summary>
|
||||||
|
private sealed record PersistedFile(int SchemaVersion, GalaxyHierarchySnapshot? Snapshot);
|
||||||
|
}
|
||||||
@@ -3,10 +3,15 @@ using Microsoft.Data.SqlClient;
|
|||||||
namespace MxGateway.Server.Galaxy;
|
namespace MxGateway.Server.Galaxy;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SQL access to the AVEVA System Platform Galaxy Repository (ZB) database. Ported from
|
/// SQL access to the AVEVA System Platform Galaxy Repository (ZB) database.
|
||||||
/// the OtOpcUa project so the row sets stay byte-for-byte identical between the two
|
/// <para>
|
||||||
/// consumers — the same SQL drives the OPC UA server's address space and this gateway's
|
/// <see cref="HierarchySql" /> is still the query originally ported from the OtOpcUa
|
||||||
/// gRPC browse surface.
|
/// project. <see cref="AttributesSql" /> has diverged: it additionally enumerates the
|
||||||
|
/// built-in attributes contributed by each object's primitives (from
|
||||||
|
/// <c>attribute_definition</c> via <c>primitive_instance</c>), so engine/platform objects
|
||||||
|
/// and extension sub-attributes (e.g. <c>TestAlarm001.Acked</c>) are surfaced. The
|
||||||
|
/// OtOpcUa query is not kept in sync — see docs/GalaxyRepository.md.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository
|
public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository
|
||||||
{
|
{
|
||||||
@@ -158,6 +163,16 @@ WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
|||||||
AND g.deployed_package_id <> 0
|
AND g.deployed_package_id <> 0
|
||||||
ORDER BY parent_gobject_id, g.tag_name";
|
ORDER BY parent_gobject_id, g.tag_name";
|
||||||
|
|
||||||
|
// Unlike HierarchySql, this query has diverged from the OtOpcUa original. It returns two
|
||||||
|
// kinds of attribute: user-configured dynamic attributes (the original `dynamic_attribute`
|
||||||
|
// body, src_pri 0) and the built-in attributes every object inherits from its primitives
|
||||||
|
// (`attribute_definition` joined through `primitive_instance`, src_pri 1). Built-in
|
||||||
|
// attributes are why engine/platform objects and extension sub-attributes such as
|
||||||
|
// `TestAlarm001.Acked` show up at all. Built-in rows carry no category filter (the
|
||||||
|
// `attribute_definition` category numbering differs from `dynamic_attribute`'s — only the
|
||||||
|
// `_`-prefix and `.Description` name exclusions apply) and are never flagged
|
||||||
|
// `is_historized`/`is_alarm`: those flags describe a user attribute that anchors an
|
||||||
|
// extension, not the extension's machinery leaves. See docs/GalaxyRepository.md.
|
||||||
private const string AttributesSql = @"
|
private const string AttributesSql = @"
|
||||||
;WITH deployed_package_chain AS (
|
;WITH deployed_package_chain AS (
|
||||||
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
|
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
|
||||||
@@ -169,58 +184,69 @@ ORDER BY parent_gobject_id, g.tag_name";
|
|||||||
FROM deployed_package_chain dpc
|
FROM deployed_package_chain dpc
|
||||||
INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
|
INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
|
||||||
WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
|
WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
|
||||||
)
|
),
|
||||||
SELECT gobject_id, tag_name, attribute_name, full_tag_reference,
|
candidate AS (
|
||||||
mx_data_type, data_type_name, is_array, array_dimension,
|
|
||||||
mx_attribute_category, security_classification, is_historized, is_alarm
|
|
||||||
FROM (
|
|
||||||
SELECT
|
SELECT
|
||||||
dpc.gobject_id,
|
dpc.gobject_id, g.tag_name, da.attribute_name, da.mx_data_type, da.is_array,
|
||||||
g.tag_name,
|
|
||||||
da.attribute_name,
|
|
||||||
g.tag_name + '.' + da.attribute_name
|
|
||||||
+ CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END
|
|
||||||
AS full_tag_reference,
|
|
||||||
da.mx_data_type,
|
|
||||||
dt.description AS data_type_name,
|
|
||||||
da.is_array,
|
|
||||||
CASE WHEN da.is_array = 1
|
CASE WHEN da.is_array = 1
|
||||||
THEN CONVERT(int, CONVERT(varbinary(2),
|
THEN CONVERT(int, CONVERT(varbinary(2),
|
||||||
SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
|
SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
|
||||||
ELSE NULL
|
ELSE NULL END AS array_dimension,
|
||||||
END AS array_dimension,
|
da.mx_attribute_category, da.security_classification, dpc.depth, 0 AS src_pri
|
||||||
da.mx_attribute_category,
|
|
||||||
da.security_classification,
|
|
||||||
CASE WHEN EXISTS (
|
|
||||||
SELECT 1 FROM deployed_package_chain dpc2
|
|
||||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
|
|
||||||
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
|
|
||||||
WHERE dpc2.gobject_id = dpc.gobject_id
|
|
||||||
) THEN 1 ELSE 0 END AS is_historized,
|
|
||||||
CASE WHEN EXISTS (
|
|
||||||
SELECT 1 FROM deployed_package_chain dpc2
|
|
||||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
|
|
||||||
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
|
|
||||||
WHERE dpc2.gobject_id = dpc.gobject_id
|
|
||||||
) THEN 1 ELSE 0 END AS is_alarm,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY dpc.gobject_id, da.attribute_name
|
|
||||||
ORDER BY dpc.depth
|
|
||||||
) AS rn
|
|
||||||
FROM deployed_package_chain dpc
|
FROM deployed_package_chain dpc
|
||||||
INNER JOIN dynamic_attribute da
|
INNER JOIN dynamic_attribute da ON da.package_id = dpc.package_id
|
||||||
ON da.package_id = dpc.package_id
|
INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id
|
||||||
INNER JOIN gobject g
|
INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id
|
||||||
ON g.gobject_id = dpc.gobject_id
|
|
||||||
INNER JOIN template_definition td
|
|
||||||
ON td.template_definition_id = g.template_definition_id
|
|
||||||
LEFT JOIN data_type dt
|
|
||||||
ON dt.mx_data_type = da.mx_data_type
|
|
||||||
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||||
AND da.attribute_name NOT LIKE '[_]%'
|
AND da.attribute_name NOT LIKE '[_]%'
|
||||||
AND da.attribute_name NOT LIKE '%.Description'
|
AND da.attribute_name NOT LIKE '%.Description'
|
||||||
AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
|
AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
|
||||||
) ranked
|
UNION ALL
|
||||||
WHERE rn = 1
|
SELECT
|
||||||
ORDER BY tag_name, attribute_name";
|
dpc.gobject_id, g.tag_name,
|
||||||
|
CASE WHEN pi.primitive_name IS NULL OR pi.primitive_name = ''
|
||||||
|
THEN ad.attribute_name
|
||||||
|
ELSE pi.primitive_name + '.' + ad.attribute_name END AS attribute_name,
|
||||||
|
ad.mx_data_type, ad.is_array,
|
||||||
|
CASE WHEN ad.is_array = 1
|
||||||
|
THEN CONVERT(int, CONVERT(varbinary(2),
|
||||||
|
SUBSTRING(ad.mx_value, 15, 2) + SUBSTRING(ad.mx_value, 13, 2), 2))
|
||||||
|
ELSE NULL END AS array_dimension,
|
||||||
|
ad.mx_attribute_category, ad.security_classification, dpc.depth, 1 AS src_pri
|
||||||
|
FROM deployed_package_chain dpc
|
||||||
|
INNER JOIN primitive_instance pi ON pi.package_id = dpc.package_id
|
||||||
|
INNER JOIN attribute_definition ad ON ad.primitive_definition_id = pi.primitive_definition_id
|
||||||
|
INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id
|
||||||
|
INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id
|
||||||
|
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||||
|
AND ad.attribute_name NOT LIKE '[_]%'
|
||||||
|
AND ad.attribute_name NOT LIKE '%.Description'
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT c.*, ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY c.gobject_id, c.attribute_name ORDER BY c.src_pri, c.depth) AS rn
|
||||||
|
FROM candidate c
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
r.gobject_id, r.tag_name, r.attribute_name,
|
||||||
|
r.tag_name + '.' + r.attribute_name
|
||||||
|
+ CASE WHEN r.is_array = 1 THEN '[]' ELSE '' END AS full_tag_reference,
|
||||||
|
r.mx_data_type, dt.description AS data_type_name, r.is_array, r.array_dimension,
|
||||||
|
r.mx_attribute_category, r.security_classification,
|
||||||
|
CASE WHEN r.src_pri = 0 AND EXISTS (
|
||||||
|
SELECT 1 FROM deployed_package_chain dpc2
|
||||||
|
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name
|
||||||
|
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
|
||||||
|
WHERE dpc2.gobject_id = r.gobject_id
|
||||||
|
) THEN 1 ELSE 0 END AS is_historized,
|
||||||
|
CASE WHEN r.src_pri = 0 AND EXISTS (
|
||||||
|
SELECT 1 FROM deployed_package_chain dpc2
|
||||||
|
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name
|
||||||
|
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
|
||||||
|
WHERE dpc2.gobject_id = r.gobject_id
|
||||||
|
) THEN 1 ELSE 0 END AS is_alarm
|
||||||
|
FROM ranked r
|
||||||
|
LEFT JOIN data_type dt ON dt.mx_data_type = r.mx_data_type
|
||||||
|
WHERE r.rn = 1
|
||||||
|
ORDER BY r.tag_name, r.attribute_name";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,4 +27,21 @@ public sealed class GalaxyRepositoryOptions
|
|||||||
/// cache. SQL is hit at most once per interval regardless of dashboard render rate.
|
/// cache. SQL is hit at most once per interval regardless of dashboard render rate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int DashboardRefreshIntervalSeconds { get; init; } = 30;
|
public int DashboardRefreshIntervalSeconds { get; init; } = 30;
|
||||||
|
|
||||||
|
/// <summary>Default on-disk path for the persisted Galaxy browse snapshot.</summary>
|
||||||
|
public const string DefaultSnapshotCachePath =
|
||||||
|
@"C:\ProgramData\MxGateway\galaxy-snapshot.json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the gateway persists the latest successful Galaxy browse dataset to
|
||||||
|
/// disk. When enabled, the cache reloads that snapshot at startup so clients can
|
||||||
|
/// still browse last-known data while the Galaxy database is unreachable.
|
||||||
|
/// </summary>
|
||||||
|
public bool PersistSnapshot { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// File path for the persisted Galaxy browse snapshot. Ignored when
|
||||||
|
/// <see cref="PersistSnapshot"/> is <see langword="false"/>.
|
||||||
|
/// </summary>
|
||||||
|
public string SnapshotCachePath { get; init; } = DefaultSnapshotCachePath;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public static class GalaxyRepositoryServiceCollectionExtensions
|
|||||||
services.AddSingleton<IGalaxyRepository>(sp => sp.GetRequiredService<GalaxyRepository>());
|
services.AddSingleton<IGalaxyRepository>(sp => sp.GetRequiredService<GalaxyRepository>());
|
||||||
|
|
||||||
services.AddSingleton<IGalaxyDeployNotifier, GalaxyDeployNotifier>();
|
services.AddSingleton<IGalaxyDeployNotifier, GalaxyDeployNotifier>();
|
||||||
|
services.AddSingleton<IGalaxyHierarchySnapshotStore, GalaxyHierarchySnapshotStore>();
|
||||||
services.AddSingleton<IGalaxyHierarchyCache, GalaxyHierarchyCache>();
|
services.AddSingleton<IGalaxyHierarchyCache, GalaxyHierarchyCache>();
|
||||||
services.AddHostedService<GalaxyHierarchyRefreshService>();
|
services.AddHostedService<GalaxyHierarchyRefreshService>();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace MxGateway.Server.Galaxy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the latest Galaxy Repository browse dataset to disk and reloads
|
||||||
|
/// it at startup. Lets <see cref="GalaxyHierarchyCache"/> serve last-known
|
||||||
|
/// browse data when the Galaxy database is unreachable on a cold start.
|
||||||
|
/// </summary>
|
||||||
|
public interface IGalaxyHierarchySnapshotStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Writes <paramref name="snapshot"/> to disk, replacing any previous
|
||||||
|
/// snapshot atomically. A no-op when snapshot persistence is disabled.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="snapshot">The browse dataset to persist.</param>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the persisted Galaxy browse dataset.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The persisted snapshot, or <see langword="null"/> when none exists,
|
||||||
|
/// persistence is disabled, or the on-disk file uses an unrecognized
|
||||||
|
/// schema version.
|
||||||
|
/// </returns>
|
||||||
|
Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
||||||
using MxGateway.Contracts;
|
using MxGateway.Contracts;
|
||||||
|
using MxGateway.Server.Alarms;
|
||||||
using MxGateway.Server.Configuration;
|
using MxGateway.Server.Configuration;
|
||||||
using MxGateway.Server.Dashboard;
|
using MxGateway.Server.Dashboard;
|
||||||
using MxGateway.Server.Diagnostics;
|
using MxGateway.Server.Diagnostics;
|
||||||
@@ -64,6 +65,7 @@ public static class GatewayApplication
|
|||||||
builder.Services.AddSingleton<IEventStreamService, EventStreamService>();
|
builder.Services.AddSingleton<IEventStreamService, EventStreamService>();
|
||||||
builder.Services.AddWorkerProcessLauncher();
|
builder.Services.AddWorkerProcessLauncher();
|
||||||
builder.Services.AddGatewaySessions();
|
builder.Services.AddGatewaySessions();
|
||||||
|
builder.Services.AddGatewayAlarms();
|
||||||
builder.Services.AddGatewayDashboard();
|
builder.Services.AddGatewayDashboard();
|
||||||
builder.Services.AddGalaxyRepository();
|
builder.Services.AddGalaxyRepository();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Grpc.Core;
|
|||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using MxGateway.Contracts;
|
using MxGateway.Contracts;
|
||||||
using MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Alarms;
|
||||||
using MxGateway.Server.Metrics;
|
using MxGateway.Server.Metrics;
|
||||||
using MxGateway.Server.Security.Authentication;
|
using MxGateway.Server.Security.Authentication;
|
||||||
using MxGateway.Server.Security.Authorization;
|
using MxGateway.Server.Security.Authorization;
|
||||||
@@ -21,9 +22,8 @@ public sealed class MxAccessGatewayService(
|
|||||||
IEventStreamService eventStreamService,
|
IEventStreamService eventStreamService,
|
||||||
GatewayMetrics metrics,
|
GatewayMetrics metrics,
|
||||||
ILogger<MxAccessGatewayService> logger,
|
ILogger<MxAccessGatewayService> logger,
|
||||||
IAlarmRpcDispatcher? alarmRpcDispatcher = null) : MxAccessGateway.MxAccessGatewayBase
|
IGatewayAlarmService alarmService) : MxAccessGateway.MxAccessGatewayBase
|
||||||
{
|
{
|
||||||
private readonly IAlarmRpcDispatcher alarmRpcDispatcher = alarmRpcDispatcher ?? new NotWiredAlarmRpcDispatcher();
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override async Task<OpenSessionReply> OpenSession(
|
public override async Task<OpenSessionReply> OpenSession(
|
||||||
OpenSessionRequest request,
|
OpenSessionRequest request,
|
||||||
@@ -163,14 +163,13 @@ public sealed class MxAccessGatewayService(
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Surfaces the public AcknowledgeAlarm RPC. The gateway validates the request,
|
/// Surfaces the public AcknowledgeAlarm RPC. Acknowledgement is
|
||||||
/// resolves the session, and delegates to the registered
|
/// session-less: the gateway routes it through the always-on
|
||||||
/// <see cref="IAlarmRpcDispatcher"/>. DI binds the production
|
/// <see cref="IGatewayAlarmService"/> monitor session. An
|
||||||
/// <see cref="MxGateway.Server.Sessions.WorkerAlarmRpcDispatcher"/>, which routes
|
/// <c>alarm_full_reference</c> that parses as a canonical GUID forwards
|
||||||
/// the ack through the worker pipe IPC: an <c>alarm_full_reference</c> that parses
|
/// to <c>AcknowledgeAlarmCommand</c>; a <c>Provider!Group.Tag</c>
|
||||||
/// as a canonical GUID forwards to <c>AcknowledgeAlarmCommand</c>; a
|
/// reference forwards to <c>AcknowledgeAlarmByNameCommand</c>; anything
|
||||||
/// <c>Provider!Group.Tag</c> reference forwards to <c>AcknowledgeAlarmByNameCommand</c>;
|
/// else returns an <c>InvalidRequest</c> diagnostic in the reply.
|
||||||
/// anything else returns an <c>InvalidRequest</c> diagnostic.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public override async Task<AcknowledgeAlarmReply> AcknowledgeAlarm(
|
public override async Task<AcknowledgeAlarmReply> AcknowledgeAlarm(
|
||||||
AcknowledgeAlarmRequest request,
|
AcknowledgeAlarmRequest request,
|
||||||
@@ -179,25 +178,12 @@ public sealed class MxAccessGatewayService(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
if (string.IsNullOrEmpty(request.SessionId))
|
|
||||||
{
|
|
||||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "session_id is required."));
|
|
||||||
}
|
|
||||||
if (string.IsNullOrEmpty(request.AlarmFullReference))
|
if (string.IsNullOrEmpty(request.AlarmFullReference))
|
||||||
{
|
{
|
||||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "alarm_full_reference is required."));
|
throw new RpcException(new Status(StatusCode.InvalidArgument, "alarm_full_reference is required."));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the session exists. Throws SessionManagerException → mapped to
|
return await alarmService.AcknowledgeAsync(request, context.CancellationToken)
|
||||||
// gRPC NotFound by the caller's MapException.
|
|
||||||
_ = ResolveSession(request.SessionId);
|
|
||||||
|
|
||||||
// Delegate to the registered alarm dispatcher. DI binds the production
|
|
||||||
// WorkerAlarmRpcDispatcher, which routes the ack over the worker IPC by
|
|
||||||
// GUID (AcknowledgeAlarmCommand) or by Provider!Group.Tag reference
|
|
||||||
// (AcknowledgeAlarmByNameCommand). NotWiredAlarmRpcDispatcher is only the
|
|
||||||
// null fallback used when no dispatcher is registered.
|
|
||||||
return await alarmRpcDispatcher.AcknowledgeAsync(request, context.CancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (exception is not RpcException)
|
catch (Exception exception) when (exception is not RpcException)
|
||||||
@@ -208,38 +194,27 @@ public sealed class MxAccessGatewayService(
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Surfaces the public QueryActiveAlarms RPC. The gateway validates the request,
|
/// Surfaces the public StreamAlarms RPC — the session-less central
|
||||||
/// resolves the session, and delegates to the registered
|
/// alarm feed. The stream opens with one <c>active_alarm</c> per
|
||||||
/// <see cref="IAlarmRpcDispatcher"/>. DI binds the production
|
/// currently-active alarm, then a single <c>snapshot_complete</c>, then
|
||||||
/// <see cref="MxGateway.Server.Sessions.WorkerAlarmRpcDispatcher"/>, which issues a
|
/// a <c>transition</c> for every subsequent change. Served by the
|
||||||
/// <c>QueryActiveAlarmsCommand</c> over the worker pipe IPC and streams each
|
/// gateway's always-on <see cref="IGatewayAlarmService"/> monitor; any
|
||||||
/// <c>ActiveAlarmSnapshot</c> from the worker reply.
|
/// number of clients fan out from the single monitor.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public override async Task QueryActiveAlarms(
|
public override async Task StreamAlarms(
|
||||||
QueryActiveAlarmsRequest request,
|
StreamAlarmsRequest request,
|
||||||
IServerStreamWriter<ActiveAlarmSnapshot> responseStream,
|
IServerStreamWriter<AlarmFeedMessage> responseStream,
|
||||||
ServerCallContext context)
|
ServerCallContext context)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
if (string.IsNullOrEmpty(request.SessionId))
|
await foreach (AlarmFeedMessage message in alarmService
|
||||||
{
|
.StreamAsync(request.AlarmFilterPrefix, context.CancellationToken)
|
||||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "session_id is required."));
|
|
||||||
}
|
|
||||||
_ = ResolveSession(request.SessionId);
|
|
||||||
|
|
||||||
// Delegate to the registered alarm dispatcher. DI binds the production
|
|
||||||
// WorkerAlarmRpcDispatcher, which issues a QueryActiveAlarmsCommand over the
|
|
||||||
// worker IPC and streams each ActiveAlarmSnapshot from the worker reply.
|
|
||||||
// NotWiredAlarmRpcDispatcher is only the null fallback used when no
|
|
||||||
// dispatcher is registered.
|
|
||||||
await foreach (ActiveAlarmSnapshot snapshot in alarmRpcDispatcher
|
|
||||||
.QueryActiveAlarmsAsync(request, context.CancellationToken)
|
|
||||||
.WithCancellation(context.CancellationToken)
|
.WithCancellation(context.CancellationToken)
|
||||||
.ConfigureAwait(false))
|
.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
await responseStream.WriteAsync(snapshot).ConfigureAwait(false);
|
await responseStream.WriteAsync(message).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (exception is not RpcException)
|
catch (Exception exception) when (exception is not RpcException)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public sealed class GatewayGrpcScopeResolver
|
|||||||
StreamEventsRequest => GatewayScopes.EventsRead,
|
StreamEventsRequest => GatewayScopes.EventsRead,
|
||||||
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
|
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
|
||||||
AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite,
|
AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite,
|
||||||
QueryActiveAlarmsRequest => GatewayScopes.EventsRead,
|
StreamAlarmsRequest => GatewayScopes.EventsRead,
|
||||||
TestConnectionRequest or
|
TestConnectionRequest or
|
||||||
GetLastDeployTimeRequest or
|
GetLastDeployTimeRequest or
|
||||||
DiscoverHierarchyRequest or
|
DiscoverHierarchyRequest or
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using MxGateway.Contracts.Proto;
|
|
||||||
|
|
||||||
namespace MxGateway.Server.Sessions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gateway-side dispatcher seam for the alarm-RPC surface. Bridges the
|
|
||||||
/// public <c>AcknowledgeAlarm</c> + <c>QueryActiveAlarms</c> gRPC handlers
|
|
||||||
/// to the worker process that hosts <c>IMxAccessAlarmConsumer</c>.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// DI binds the production <see cref="WorkerAlarmRpcDispatcher"/> by
|
|
||||||
/// default; it routes calls through the existing worker-pipe IPC.
|
|
||||||
/// <c>NotWiredAlarmRpcDispatcher</c> is only the null fallback used
|
|
||||||
/// when no dispatcher is registered (DI omission / standalone tests).
|
|
||||||
/// Other tests inject a fake to exercise the gateway handler shape
|
|
||||||
/// without spinning up a worker process.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// The dispatcher is session-scoped: every call resolves the
|
|
||||||
/// session and forwards to that session's worker. The handler
|
|
||||||
/// constructs the <see cref="AcknowledgeAlarmReply"/> /
|
|
||||||
/// <see cref="ActiveAlarmSnapshot"/> stream from the dispatcher's
|
|
||||||
/// output without further translation.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
public interface IAlarmRpcDispatcher
|
|
||||||
{
|
|
||||||
/// <summary>Forward an Acknowledge to the worker that owns the session.</summary>
|
|
||||||
Task<AcknowledgeAlarmReply> AcknowledgeAsync(
|
|
||||||
AcknowledgeAlarmRequest request,
|
|
||||||
CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>Walk active alarms on the worker that owns the session.</summary>
|
|
||||||
IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
|
||||||
QueryActiveAlarmsRequest request,
|
|
||||||
CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using MxGateway.Contracts.Proto;
|
|
||||||
using MxGateway.Server.Grpc;
|
|
||||||
|
|
||||||
namespace MxGateway.Server.Sessions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Null fallback <see cref="IAlarmRpcDispatcher"/> used when no dispatcher
|
|
||||||
/// is registered in the DI container (DI omission or standalone tests).
|
|
||||||
/// Acknowledges with a structured "alarm dispatcher not registered"
|
|
||||||
/// diagnostic and yields an empty active-alarm stream.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// Production wires <see cref="WorkerAlarmRpcDispatcher"/> as the
|
|
||||||
/// default <see cref="IAlarmRpcDispatcher"/> via
|
|
||||||
/// <c>SessionServiceCollectionExtensions.AddGatewaySessions</c>, so
|
|
||||||
/// clients that hit this fallback are running against an
|
|
||||||
/// intentionally minimal service composition rather than the full
|
|
||||||
/// gateway.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
public sealed class NotWiredAlarmRpcDispatcher : IAlarmRpcDispatcher
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<AcknowledgeAlarmReply> AcknowledgeAsync(
|
|
||||||
AcknowledgeAlarmRequest request,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return Task.FromResult(new AcknowledgeAlarmReply
|
|
||||||
{
|
|
||||||
SessionId = request.SessionId,
|
|
||||||
CorrelationId = request.ClientCorrelationId,
|
|
||||||
ProtocolStatus = MxAccessGrpcMapper.Ok("AcknowledgeAlarm accepted; alarm dispatcher is not registered."),
|
|
||||||
DiagnosticMessage = "Alarm dispatcher is not registered.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
#pragma warning disable CS1998 // Async method lacks 'await' operators — empty stream is intentional.
|
|
||||||
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
|
||||||
QueryActiveAlarmsRequest request,
|
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
yield break;
|
|
||||||
}
|
|
||||||
#pragma warning restore CS1998
|
|
||||||
}
|
|
||||||
@@ -90,8 +90,6 @@ public sealed class SessionManager : ISessionManager
|
|||||||
_metrics.SessionOpened();
|
_metrics.SessionOpened();
|
||||||
sessionOpenedRecorded = true;
|
sessionOpenedRecorded = true;
|
||||||
|
|
||||||
await TryAutoSubscribeAlarmsAsync(session, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
@@ -410,100 +408,4 @@ public sealed class SessionManager : ISessionManager
|
|||||||
return Convert.ToBase64String(bytes);
|
return Convert.ToBase64String(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// If <c>Alarms.Enabled</c> is configured, issue a
|
|
||||||
/// <c>SubscribeAlarmsCommand</c> on the freshly-Ready session so the
|
|
||||||
/// worker's wnwrap consumer starts polling. Failure handling is
|
|
||||||
/// governed by <c>Alarms.RequireSubscribeOnOpen</c>:
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item><description><c>true</c> — propagate the failure to fault the session.</description></item>
|
|
||||||
/// <item><description><c>false</c> (default) — log a warning and let the session continue serving data subscriptions.</description></item>
|
|
||||||
/// </list>
|
|
||||||
/// </summary>
|
|
||||||
private async Task TryAutoSubscribeAlarmsAsync(
|
|
||||||
GatewaySession session,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
AlarmsOptions alarms = _options.Alarms;
|
|
||||||
if (!alarms.Enabled) return;
|
|
||||||
|
|
||||||
string subscription = ResolveAlarmSubscription(alarms);
|
|
||||||
if (string.IsNullOrWhiteSpace(subscription))
|
|
||||||
{
|
|
||||||
const string diagnostic =
|
|
||||||
"Alarms.Enabled is true but no SubscriptionExpression / DefaultArea is configured.";
|
|
||||||
if (alarms.RequireSubscribeOnOpen)
|
|
||||||
{
|
|
||||||
throw new SessionManagerException(
|
|
||||||
SessionManagerErrorCode.OpenFailed, diagnostic);
|
|
||||||
}
|
|
||||||
_logger.LogWarning(
|
|
||||||
"Auto-subscribe skipped for session {SessionId}: {Diagnostic}",
|
|
||||||
session.SessionId, diagnostic);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkerCommand command = new WorkerCommand
|
|
||||||
{
|
|
||||||
Command = new MxCommand
|
|
||||||
{
|
|
||||||
Kind = MxCommandKind.SubscribeAlarms,
|
|
||||||
SubscribeAlarms = new SubscribeAlarmsCommand
|
|
||||||
{
|
|
||||||
SubscriptionExpression = subscription,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
EnqueueTimestamp = Timestamp.FromDateTimeOffset(_timeProvider.GetUtcNow()),
|
|
||||||
};
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
WorkerCommandReply reply = await session.InvokeAsync(command, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
ProtocolStatusCode? code = reply.Reply?.ProtocolStatus?.Code;
|
|
||||||
if (code != ProtocolStatusCode.Ok)
|
|
||||||
{
|
|
||||||
string diagnostic = reply.Reply?.DiagnosticMessage
|
|
||||||
?? reply.Reply?.ProtocolStatus?.Message
|
|
||||||
?? "Worker rejected SubscribeAlarms.";
|
|
||||||
if (alarms.RequireSubscribeOnOpen)
|
|
||||||
{
|
|
||||||
throw new SessionManagerException(
|
|
||||||
SessionManagerErrorCode.OpenFailed,
|
|
||||||
$"Auto-subscribe failed for session {session.SessionId}: {diagnostic}");
|
|
||||||
}
|
|
||||||
_logger.LogWarning(
|
|
||||||
"Auto-subscribe failed for session {SessionId} (status {StatusCode}): {Diagnostic}",
|
|
||||||
session.SessionId, code, diagnostic);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Alarm auto-subscribe succeeded for session {SessionId} on {Subscription}.",
|
|
||||||
session.SessionId, subscription);
|
|
||||||
}
|
|
||||||
catch (SessionManagerException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (!alarms.RequireSubscribeOnOpen)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(
|
|
||||||
ex,
|
|
||||||
"Auto-subscribe threw for session {SessionId} on {Subscription}; alarm path remains inactive.",
|
|
||||||
session.SessionId, subscription);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ResolveAlarmSubscription(AlarmsOptions alarms)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(alarms.SubscriptionExpression))
|
|
||||||
{
|
|
||||||
return alarms.SubscriptionExpression;
|
|
||||||
}
|
|
||||||
if (!string.IsNullOrWhiteSpace(alarms.DefaultArea))
|
|
||||||
{
|
|
||||||
return $@"\\{Environment.MachineName}\Galaxy!{alarms.DefaultArea}";
|
|
||||||
}
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ public static class SessionServiceCollectionExtensions
|
|||||||
services.AddSingleton<ISessionRegistry, SessionRegistry>();
|
services.AddSingleton<ISessionRegistry, SessionRegistry>();
|
||||||
services.AddSingleton<ISessionWorkerClientFactory, SessionWorkerClientFactory>();
|
services.AddSingleton<ISessionWorkerClientFactory, SessionWorkerClientFactory>();
|
||||||
services.AddSingleton<ISessionManager, SessionManager>();
|
services.AddSingleton<ISessionManager, SessionManager>();
|
||||||
services.AddSingleton<IAlarmRpcDispatcher, WorkerAlarmRpcDispatcher>();
|
|
||||||
services.AddHostedService<SessionLeaseMonitorHostedService>();
|
services.AddHostedService<SessionLeaseMonitorHostedService>();
|
||||||
services.AddHostedService<SessionShutdownHostedService>();
|
services.AddHostedService<SessionShutdownHostedService>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
using System.Runtime.CompilerServices;
|
|
||||||
using Google.Protobuf.WellKnownTypes;
|
|
||||||
using MxGateway.Contracts.Proto;
|
|
||||||
using MxGateway.Server.Grpc;
|
|
||||||
|
|
||||||
namespace MxGateway.Server.Sessions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Production <see cref="IAlarmRpcDispatcher"/> that routes the public
|
|
||||||
/// <c>AcknowledgeAlarm</c> + <c>QueryActiveAlarms</c> RPCs through the
|
|
||||||
/// worker pipe IPC. DI binds this dispatcher; <see cref="NotWiredAlarmRpcDispatcher"/>
|
|
||||||
/// is only the null fallback used when no dispatcher is registered.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// <c>QueryActiveAlarms</c> issues a
|
|
||||||
/// <see cref="QueryActiveAlarmsCommand"/> over the pipe and yields
|
|
||||||
/// each <see cref="ActiveAlarmSnapshot"/> from the
|
|
||||||
/// <see cref="QueryActiveAlarmsReplyPayload"/>.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <c>AcknowledgeAlarm</c> accepts either form of
|
|
||||||
/// <see cref="AcknowledgeAlarmRequest.AlarmFullReference"/>: a canonical
|
|
||||||
/// GUID forwards as an <see cref="AcknowledgeAlarmCommand"/>; a
|
|
||||||
/// <c>Provider!Group.Tag</c> reference is parsed by
|
|
||||||
/// <see cref="TryParseAlarmReference"/> and forwarded as an
|
|
||||||
/// <see cref="AcknowledgeAlarmByNameCommand"/>. Any other reference
|
|
||||||
/// returns an <c>InvalidRequest</c> diagnostic.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
public sealed class WorkerAlarmRpcDispatcher(
|
|
||||||
ISessionRegistry sessionRegistry,
|
|
||||||
TimeProvider? timeProvider = null) : IAlarmRpcDispatcher
|
|
||||||
{
|
|
||||||
private readonly ISessionRegistry sessionRegistry = sessionRegistry
|
|
||||||
?? throw new ArgumentNullException(nameof(sessionRegistry));
|
|
||||||
private readonly TimeProvider timeProvider = timeProvider ?? TimeProvider.System;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parse a full alarm reference of the form <c>Provider!Group.Tag</c>
|
|
||||||
/// into its components. Convention: the first <c>!</c> separates
|
|
||||||
/// provider from <c>Group.Tag</c>; the first <c>.</c> after the
|
|
||||||
/// <c>!</c> separates group from tag (the tag itself may contain
|
|
||||||
/// more dots — e.g. <c>TestMachine_001.TestAlarm001</c>).
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>true on a well-formed reference; false otherwise.</returns>
|
|
||||||
public static bool TryParseAlarmReference(
|
|
||||||
string? reference,
|
|
||||||
out string providerName,
|
|
||||||
out string groupName,
|
|
||||||
out string alarmName)
|
|
||||||
{
|
|
||||||
providerName = string.Empty;
|
|
||||||
groupName = string.Empty;
|
|
||||||
alarmName = string.Empty;
|
|
||||||
if (string.IsNullOrWhiteSpace(reference)) return false;
|
|
||||||
|
|
||||||
int bang = reference!.IndexOf('!');
|
|
||||||
if (bang <= 0 || bang == reference.Length - 1) return false;
|
|
||||||
|
|
||||||
string left = reference[..bang];
|
|
||||||
string right = reference[(bang + 1)..];
|
|
||||||
int dot = right.IndexOf('.');
|
|
||||||
if (dot <= 0 || dot == right.Length - 1) return false;
|
|
||||||
|
|
||||||
providerName = left;
|
|
||||||
groupName = right[..dot];
|
|
||||||
alarmName = right[(dot + 1)..];
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<AcknowledgeAlarmReply> AcknowledgeAsync(
|
|
||||||
AcknowledgeAlarmRequest request,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
|
||||||
|
|
||||||
if (!sessionRegistry.TryGet(request.SessionId, out GatewaySession? session) || session is null)
|
|
||||||
{
|
|
||||||
return new AcknowledgeAlarmReply
|
|
||||||
{
|
|
||||||
SessionId = request.SessionId,
|
|
||||||
CorrelationId = request.ClientCorrelationId,
|
|
||||||
ProtocolStatus = MxAccessGrpcMapper.SessionNotFound(
|
|
||||||
$"Session '{request.SessionId}' not found."),
|
|
||||||
DiagnosticMessage = "AcknowledgeAlarm: session not found.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkerCommand workerCommand;
|
|
||||||
if (Guid.TryParse(request.AlarmFullReference, out Guid guid))
|
|
||||||
{
|
|
||||||
workerCommand = new WorkerCommand
|
|
||||||
{
|
|
||||||
Command = new MxCommand
|
|
||||||
{
|
|
||||||
Kind = MxCommandKind.AcknowledgeAlarm,
|
|
||||||
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
|
|
||||||
{
|
|
||||||
AlarmGuid = guid.ToString(),
|
|
||||||
Comment = request.Comment ?? string.Empty,
|
|
||||||
OperatorUser = request.OperatorUser ?? string.Empty,
|
|
||||||
// Operator node/domain/full-name are not on the public
|
|
||||||
// RPC surface today; pass empty strings so the worker
|
|
||||||
// honours the existing AcknowledgeAlarmCommand schema.
|
|
||||||
OperatorNode = string.Empty,
|
|
||||||
OperatorDomain = string.Empty,
|
|
||||||
OperatorFullName = string.Empty,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
EnqueueTimestamp = Timestamp.FromDateTimeOffset(timeProvider.GetUtcNow()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else if (TryParseAlarmReference(
|
|
||||||
request.AlarmFullReference,
|
|
||||||
out string providerName,
|
|
||||||
out string groupName,
|
|
||||||
out string alarmName))
|
|
||||||
{
|
|
||||||
workerCommand = new WorkerCommand
|
|
||||||
{
|
|
||||||
Command = new MxCommand
|
|
||||||
{
|
|
||||||
Kind = MxCommandKind.AcknowledgeAlarmByName,
|
|
||||||
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
|
|
||||||
{
|
|
||||||
AlarmName = alarmName,
|
|
||||||
ProviderName = providerName,
|
|
||||||
GroupName = groupName,
|
|
||||||
Comment = request.Comment ?? string.Empty,
|
|
||||||
OperatorUser = request.OperatorUser ?? string.Empty,
|
|
||||||
OperatorNode = string.Empty,
|
|
||||||
OperatorDomain = string.Empty,
|
|
||||||
OperatorFullName = string.Empty,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
EnqueueTimestamp = Timestamp.FromDateTimeOffset(timeProvider.GetUtcNow()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return new AcknowledgeAlarmReply
|
|
||||||
{
|
|
||||||
SessionId = request.SessionId,
|
|
||||||
CorrelationId = request.ClientCorrelationId,
|
|
||||||
ProtocolStatus = new ProtocolStatus
|
|
||||||
{
|
|
||||||
Code = ProtocolStatusCode.InvalidRequest,
|
|
||||||
Message = "AlarmFullReference must be a canonical GUID or 'Provider!Group.Tag' format.",
|
|
||||||
},
|
|
||||||
DiagnosticMessage = $"AcknowledgeAlarm received unrecognized reference '{request.AlarmFullReference}'.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkerCommandReply workerReply = await session.InvokeAsync(workerCommand, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
MxCommandReply mxReply = workerReply.Reply ?? new MxCommandReply
|
|
||||||
{
|
|
||||||
ProtocolStatus = new ProtocolStatus
|
|
||||||
{
|
|
||||||
Code = ProtocolStatusCode.ProtocolViolation,
|
|
||||||
Message = "Worker reply did not include an MxCommandReply.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
AcknowledgeAlarmReply reply = new AcknowledgeAlarmReply
|
|
||||||
{
|
|
||||||
SessionId = request.SessionId,
|
|
||||||
CorrelationId = request.ClientCorrelationId,
|
|
||||||
ProtocolStatus = mxReply.ProtocolStatus ?? MxAccessGrpcMapper.Ok(),
|
|
||||||
DiagnosticMessage = mxReply.DiagnosticMessage ?? string.Empty,
|
|
||||||
};
|
|
||||||
if (mxReply.HasHresult)
|
|
||||||
{
|
|
||||||
reply.Hresult = mxReply.Hresult;
|
|
||||||
}
|
|
||||||
return reply;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
|
||||||
QueryActiveAlarmsRequest request,
|
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
|
||||||
|
|
||||||
if (!sessionRegistry.TryGet(request.SessionId, out GatewaySession? session) || session is null)
|
|
||||||
{
|
|
||||||
// Server-019: align with AcknowledgeAsync's missing-session handling and
|
|
||||||
// surface a SessionNotFound error rather than yielding an empty stream.
|
|
||||||
// QueryActiveAlarms is server-streaming, so a thrown exception is the
|
|
||||||
// cleaner fit than an in-band ProtocolStatus; MxAccessGatewayService maps
|
|
||||||
// SessionManagerException(SessionNotFound) to gRPC NotFound.
|
|
||||||
throw new SessionManagerException(
|
|
||||||
SessionManagerErrorCode.SessionNotFound,
|
|
||||||
$"Session '{request.SessionId}' not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkerCommand workerCommand = new WorkerCommand
|
|
||||||
{
|
|
||||||
Command = new MxCommand
|
|
||||||
{
|
|
||||||
Kind = MxCommandKind.QueryActiveAlarms,
|
|
||||||
QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand
|
|
||||||
{
|
|
||||||
AlarmFilterPrefix = request.AlarmFilterPrefix ?? string.Empty,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
EnqueueTimestamp = Timestamp.FromDateTimeOffset(timeProvider.GetUtcNow()),
|
|
||||||
};
|
|
||||||
|
|
||||||
WorkerCommandReply workerReply = await session.InvokeAsync(workerCommand, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
MxCommandReply? mxReply = workerReply.Reply;
|
|
||||||
if (mxReply?.ProtocolStatus?.Code != ProtocolStatusCode.Ok) yield break;
|
|
||||||
|
|
||||||
QueryActiveAlarmsReplyPayload? payload = mxReply.QueryActiveAlarms;
|
|
||||||
if (payload is null) yield break;
|
|
||||||
|
|
||||||
foreach (ActiveAlarmSnapshot snapshot in payload.Snapshots)
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
yield return snapshot;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -389,7 +389,17 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Monitors worker heartbeat and detects stale sessions.</summary>
|
/// <summary>
|
||||||
|
/// Monitors worker heartbeat and detects stale sessions. Mirrors
|
||||||
|
/// Worker-023 on the worker side: while a command is in flight on the
|
||||||
|
/// gateway↔worker pipe, the heartbeat watchdog is suppressed up to
|
||||||
|
/// <see cref="WorkerClientOptions.HeartbeatStuckCeiling"/> — the worker
|
||||||
|
/// may be busy executing a slow STA command and the heartbeat write may
|
||||||
|
/// be queued behind a long event-drain burst (Server-031), neither of
|
||||||
|
/// which indicates the worker is actually hung. Once the oldest pending
|
||||||
|
/// command exceeds the ceiling, the fault fires anyway so a truly stuck
|
||||||
|
/// COM call doesn't hide the worker forever.
|
||||||
|
/// </summary>
|
||||||
private async Task HeartbeatLoopAsync()
|
private async Task HeartbeatLoopAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -409,6 +419,17 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server-031: if a command is in flight and hasn't yet exceeded
|
||||||
|
// the stuck-command ceiling, the gap is more likely caused by
|
||||||
|
// pipe-write contention (event drain holding the writer lock)
|
||||||
|
// or a legitimately slow STA command than by a hung worker.
|
||||||
|
// Wait for the ceiling before faulting on heartbeat alone.
|
||||||
|
if (TryGetOldestPendingCommandAge(out TimeSpan oldestCommandAge)
|
||||||
|
&& oldestCommandAge <= _options.HeartbeatStuckCeiling)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
_metrics?.HeartbeatFailed(SessionId);
|
_metrics?.HeartbeatFailed(SessionId);
|
||||||
SetFaulted(
|
SetFaulted(
|
||||||
WorkerClientErrorCode.HeartbeatExpired,
|
WorkerClientErrorCode.HeartbeatExpired,
|
||||||
@@ -421,6 +442,35 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the age of the oldest pending command on the worker pipe,
|
||||||
|
/// measured via <see cref="TimeProvider.GetElapsedTime(long)"/> against
|
||||||
|
/// <see cref="PendingCommand.StartTimestamp"/>, or <c>false</c> when no
|
||||||
|
/// commands are pending. Used by the heartbeat watchdog (Server-031)
|
||||||
|
/// to decide whether a heartbeat gap is plausibly the result of
|
||||||
|
/// pipe-write contention rather than a hung worker.
|
||||||
|
/// </summary>
|
||||||
|
private bool TryGetOldestPendingCommandAge(out TimeSpan oldestAge)
|
||||||
|
{
|
||||||
|
long oldestStart = long.MaxValue;
|
||||||
|
foreach (PendingCommand pending in _pendingCommands.Values)
|
||||||
|
{
|
||||||
|
if (pending.StartTimestamp < oldestStart)
|
||||||
|
{
|
||||||
|
oldestStart = pending.StartTimestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldestStart == long.MaxValue)
|
||||||
|
{
|
||||||
|
oldestAge = TimeSpan.Zero;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
oldestAge = _timeProvider.GetElapsedTime(oldestStart);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Routes received envelope to appropriate handler.</summary>
|
/// <summary>Routes received envelope to appropriate handler.</summary>
|
||||||
/// <param name="envelope">The envelope to dispatch.</param>
|
/// <param name="envelope">The envelope to dispatch.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
@@ -457,7 +507,19 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Enqueues a worker event for client consumption.</summary>
|
/// <summary>
|
||||||
|
/// Enqueues a worker event for client consumption. Server-032: the
|
||||||
|
/// channel is configured with <see cref="BoundedChannelFullMode.Wait"/>
|
||||||
|
/// and a brief consumer hiccup is now tolerated for up to
|
||||||
|
/// <see cref="WorkerClientOptions.EventChannelFullModeTimeout"/>
|
||||||
|
/// (default 5s) before the worker is faulted. Pre-Server-032 the code
|
||||||
|
/// used <c>TryWrite</c> (non-blocking) which never honored the
|
||||||
|
/// configured <c>FullModeTimeout</c> — the worker faulted on the first
|
||||||
|
/// missed slot even though the wait-mode channel would have absorbed
|
||||||
|
/// the burst. The diagnostic now names the capacity, current depth, and
|
||||||
|
/// the actionable fix (attach <c>StreamEvents</c> or raise
|
||||||
|
/// <c>MxGateway:Events:QueueCapacity</c>).
|
||||||
|
/// </summary>
|
||||||
/// <param name="workerEvent">The event to enqueue.</param>
|
/// <param name="workerEvent">The event to enqueue.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
private async Task EnqueueWorkerEventAsync(
|
private async Task EnqueueWorkerEventAsync(
|
||||||
@@ -469,18 +531,41 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
_metrics?.EventReceived(SessionId, workerEvent.Event.Family.ToString());
|
_metrics?.EventReceived(SessionId, workerEvent.Event.Family.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_events.Writer.TryWrite(workerEvent))
|
if (_events.Writer.TryWrite(workerEvent))
|
||||||
{
|
{
|
||||||
_metrics?.QueueOverflow("worker-events");
|
int queueDepth = Interlocked.Increment(ref _eventQueueDepth);
|
||||||
SetFaulted(
|
_metrics?.SetWorkerEventQueueDepth(queueDepth);
|
||||||
WorkerClientErrorCode.ProtocolViolation,
|
|
||||||
"Worker event channel rejected an event.",
|
|
||||||
null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Channel is full; honor the configured wait timeout before declaring
|
||||||
|
// the consumer dead and faulting the worker (Server-032).
|
||||||
|
using CancellationTokenSource fullModeCts = CancellationTokenSource
|
||||||
|
.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
fullModeCts.CancelAfter(_options.EventChannelFullModeTimeout);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _events.Writer.WriteAsync(workerEvent, fullModeCts.Token).ConfigureAwait(false);
|
||||||
int queueDepth = Interlocked.Increment(ref _eventQueueDepth);
|
int queueDepth = Interlocked.Increment(ref _eventQueueDepth);
|
||||||
_metrics?.SetWorkerEventQueueDepth(queueDepth);
|
_metrics?.SetWorkerEventQueueDepth(queueDepth);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Only the full-mode timeout fired — the outer cancellation is
|
||||||
|
// a different concern and is rethrown by the await above when it
|
||||||
|
// triggers.
|
||||||
|
}
|
||||||
|
|
||||||
|
_metrics?.QueueOverflow("worker-events");
|
||||||
|
int depthAtOverflow = Volatile.Read(ref _eventQueueDepth);
|
||||||
|
SetFaulted(
|
||||||
|
WorkerClientErrorCode.ProtocolViolation,
|
||||||
|
$"Worker event channel rejected an event after waiting "
|
||||||
|
+ $"{_options.EventChannelFullModeTimeout.TotalMilliseconds:F0} ms; "
|
||||||
|
+ $"channel depth is {depthAtOverflow} of {_options.EventChannelCapacity} capacity. "
|
||||||
|
+ $"Attach a StreamEvents consumer or raise MxGateway:Events:QueueCapacity.",
|
||||||
|
null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Completes pending command with worker reply.</summary>
|
/// <summary>Completes pending command with worker reply.</summary>
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ public sealed class WorkerClientOptions
|
|||||||
/// <summary>Default timeout when the event queue is full.</summary>
|
/// <summary>Default timeout when the event queue is full.</summary>
|
||||||
public static readonly TimeSpan DefaultEventChannelFullModeTimeout = TimeSpan.FromSeconds(5);
|
public static readonly TimeSpan DefaultEventChannelFullModeTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default ceiling on the in-flight-command heartbeat skip. Mirrors
|
||||||
|
/// <see cref="MxGateway.Worker.Ipc.WorkerPipeSessionOptions.DefaultHeartbeatStuckCeiling"/>
|
||||||
|
/// on the worker side (Worker-023). When a command has been in flight
|
||||||
|
/// longer than this, the gateway-side heartbeat watchdog fires
|
||||||
|
/// regardless of pending commands — a truly stuck COM call shouldn't
|
||||||
|
/// hide the worker forever.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly TimeSpan DefaultHeartbeatStuckCeiling = TimeSpan.FromSeconds(75);
|
||||||
|
|
||||||
/// <summary>Initializes options with default values.</summary>
|
/// <summary>Initializes options with default values.</summary>
|
||||||
public WorkerClientOptions()
|
public WorkerClientOptions()
|
||||||
{
|
{
|
||||||
@@ -20,6 +30,7 @@ public sealed class WorkerClientOptions
|
|||||||
EventChannelCapacity = 1_024;
|
EventChannelCapacity = 1_024;
|
||||||
EventChannelFullModeTimeout = DefaultEventChannelFullModeTimeout;
|
EventChannelFullModeTimeout = DefaultEventChannelFullModeTimeout;
|
||||||
MaxPendingCommands = 128;
|
MaxPendingCommands = 128;
|
||||||
|
HeartbeatStuckCeiling = DefaultHeartbeatStuckCeiling;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Maximum allowed age of the last heartbeat before faulting the client.</summary>
|
/// <summary>Maximum allowed age of the last heartbeat before faulting the client.</summary>
|
||||||
@@ -31,9 +42,27 @@ public sealed class WorkerClientOptions
|
|||||||
/// <summary>Maximum number of events buffered before backpressure is applied.</summary>
|
/// <summary>Maximum number of events buffered before backpressure is applied.</summary>
|
||||||
public int EventChannelCapacity { get; init; }
|
public int EventChannelCapacity { get; init; }
|
||||||
|
|
||||||
/// <summary>Time to wait for the event queue to drain before faulting.</summary>
|
/// <summary>
|
||||||
|
/// Time to wait for the gateway-side event channel to drain before
|
||||||
|
/// faulting the worker. Honored by <c>EnqueueWorkerEventAsync</c> via
|
||||||
|
/// <c>WriteAsync</c>; with the channel configured for
|
||||||
|
/// <c>BoundedChannelFullMode.Wait</c>, a transient backlog only faults
|
||||||
|
/// after the configured timeout has elapsed (Server-032). Pre-Server-032
|
||||||
|
/// the field was declared but unused — overflow faulted immediately.
|
||||||
|
/// </summary>
|
||||||
public TimeSpan EventChannelFullModeTimeout { get; init; }
|
public TimeSpan EventChannelFullModeTimeout { get; init; }
|
||||||
|
|
||||||
/// <summary>Maximum number of concurrent pending commands.</summary>
|
/// <summary>Maximum number of concurrent pending commands.</summary>
|
||||||
public int MaxPendingCommands { get; init; }
|
public int MaxPendingCommands { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-031: ceiling on the in-flight-command heartbeat-skip. When
|
||||||
|
/// a command has been pending on the gateway↔worker pipe for longer
|
||||||
|
/// than this, the gateway-side <c>HeartbeatLoopAsync</c> fires the
|
||||||
|
/// <c>HeartbeatExpired</c> fault even if commands are still pending;
|
||||||
|
/// a truly stuck COM call shouldn't keep the watchdog suppressed
|
||||||
|
/// indefinitely. Mirrors Worker-023's <c>HeartbeatStuckCeiling</c> on
|
||||||
|
/// the worker side.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan HeartbeatStuckCeiling { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,13 +65,15 @@
|
|||||||
"Galaxy": {
|
"Galaxy": {
|
||||||
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
|
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
|
||||||
"CommandTimeoutSeconds": 60,
|
"CommandTimeoutSeconds": 60,
|
||||||
"DashboardRefreshIntervalSeconds": 30
|
"DashboardRefreshIntervalSeconds": 30,
|
||||||
|
"PersistSnapshot": true,
|
||||||
|
"SnapshotCachePath": "C:\\ProgramData\\MxGateway\\galaxy-snapshot.json"
|
||||||
},
|
},
|
||||||
"Alarms": {
|
"Alarms": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"SubscriptionExpression": "\\\\DESKTOP-6JL3KKO\\Galaxy!DEV",
|
"SubscriptionExpression": "\\\\DESKTOP-6JL3KKO\\Galaxy!DEV",
|
||||||
"DefaultArea": "",
|
"DefaultArea": "",
|
||||||
"RequireSubscribeOnOpen": false
|
"ReconcileIntervalSeconds": 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public sealed class ProtobufContractRoundTripTests
|
|||||||
Assert.Contains(service.Methods, method => method.Name == "Invoke");
|
Assert.Contains(service.Methods, method => method.Name == "Invoke");
|
||||||
Assert.Contains(service.Methods, method => method.Name == "StreamEvents");
|
Assert.Contains(service.Methods, method => method.Name == "StreamEvents");
|
||||||
Assert.Contains(service.Methods, method => method.Name == "AcknowledgeAlarm");
|
Assert.Contains(service.Methods, method => method.Name == "AcknowledgeAlarm");
|
||||||
Assert.Contains(service.Methods, method => method.Name == "QueryActiveAlarms");
|
Assert.Contains(service.Methods, method => method.Name == "StreamAlarms");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that worker envelope descriptor contains required correlation fields.</summary>
|
/// <summary>Verifies that worker envelope descriptor contains required correlation fields.</summary>
|
||||||
@@ -306,7 +306,6 @@ public sealed class ProtobufContractRoundTripTests
|
|||||||
{
|
{
|
||||||
var original = new AcknowledgeAlarmRequest
|
var original = new AcknowledgeAlarmRequest
|
||||||
{
|
{
|
||||||
SessionId = "session-1",
|
|
||||||
ClientCorrelationId = "client-correlation-7",
|
ClientCorrelationId = "client-correlation-7",
|
||||||
AlarmFullReference = "Tank01.Level.HiHi",
|
AlarmFullReference = "Tank01.Level.HiHi",
|
||||||
Comment = "shift handover",
|
Comment = "shift handover",
|
||||||
@@ -324,7 +323,6 @@ public sealed class ProtobufContractRoundTripTests
|
|||||||
{
|
{
|
||||||
var original = new AcknowledgeAlarmReply
|
var original = new AcknowledgeAlarmReply
|
||||||
{
|
{
|
||||||
SessionId = "session-1",
|
|
||||||
CorrelationId = "gateway-correlation-7",
|
CorrelationId = "gateway-correlation-7",
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
Hresult = 0,
|
Hresult = 0,
|
||||||
@@ -420,25 +418,23 @@ public sealed class ProtobufContractRoundTripTests
|
|||||||
Assert.Equal(AlarmConditionState.ActiveAcked, parsed.CurrentState);
|
Assert.Equal(AlarmConditionState.ActiveAcked, parsed.CurrentState);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that QueryActiveAlarmsRequest round-trips empty filter prefix.</summary>
|
/// <summary>Verifies that StreamAlarmsRequest round-trips with and without a filter prefix.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void QueryActiveAlarmsRequest_RoundTripsWithAndWithoutFilter()
|
public void StreamAlarmsRequest_RoundTripsWithAndWithoutFilter()
|
||||||
{
|
{
|
||||||
var withoutFilter = new QueryActiveAlarmsRequest
|
var withoutFilter = new StreamAlarmsRequest
|
||||||
{
|
{
|
||||||
SessionId = "session-1",
|
|
||||||
ClientCorrelationId = "client-correlation-8",
|
ClientCorrelationId = "client-correlation-8",
|
||||||
};
|
};
|
||||||
|
|
||||||
var withFilter = new QueryActiveAlarmsRequest
|
var withFilter = new StreamAlarmsRequest
|
||||||
{
|
{
|
||||||
SessionId = "session-1",
|
|
||||||
ClientCorrelationId = "client-correlation-9",
|
ClientCorrelationId = "client-correlation-9",
|
||||||
AlarmFilterPrefix = "Tank01.",
|
AlarmFilterPrefix = "Tank01.",
|
||||||
};
|
};
|
||||||
|
|
||||||
Assert.Equal(withoutFilter, QueryActiveAlarmsRequest.Parser.ParseFrom(withoutFilter.ToByteArray()));
|
Assert.Equal(withoutFilter, StreamAlarmsRequest.Parser.ParseFrom(withoutFilter.ToByteArray()));
|
||||||
Assert.Equal(withFilter, QueryActiveAlarmsRequest.Parser.ParseFrom(withFilter.ToByteArray()));
|
Assert.Equal(withFilter, StreamAlarmsRequest.Parser.ParseFrom(withFilter.ToByteArray()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that an MxValue carrying a raw_value bytes payload round-trips.</summary>
|
/// <summary>Verifies that an MxValue carrying a raw_value bytes payload round-trips.</summary>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using MxGateway.Server.Galaxy;
|
using MxGateway.Server.Galaxy;
|
||||||
using MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
using MxGateway.Tests.TestSupport;
|
using MxGateway.Tests.TestSupport;
|
||||||
|
|
||||||
namespace MxGateway.Tests.Galaxy;
|
namespace MxGateway.Tests.Galaxy;
|
||||||
|
|
||||||
public sealed class GalaxyHierarchyCacheTests
|
public sealed class GalaxyHierarchyCacheTests : IDisposable
|
||||||
{
|
{
|
||||||
|
private readonly List<string> _tempPaths = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies cache returns empty entry before any refresh occurs.
|
/// Verifies cache returns empty entry before any refresh occurs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -121,6 +125,345 @@ public sealed class GalaxyHierarchyCacheTests
|
|||||||
Assert.Same(root, index.ObjectViewsById[1].Object);
|
Assert.Same(root, index.ObjectViewsById[1].Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies a successful refresh writes the browse dataset to the on-disk
|
||||||
|
/// snapshot store so a later cold start can restore it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshAsync_WhenSuccessful_PersistsSnapshotToDisk()
|
||||||
|
{
|
||||||
|
GalaxyDeployNotifier notifier = new();
|
||||||
|
StubGalaxyRepository repository = new(
|
||||||
|
deployTime: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc),
|
||||||
|
hierarchy: [SampleHierarchyRow()],
|
||||||
|
attributes: [SampleAttributeRow()]);
|
||||||
|
GalaxyHierarchySnapshotStore store = CreateStore();
|
||||||
|
GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store);
|
||||||
|
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);
|
||||||
|
GalaxyHierarchySnapshot? persisted = await store.TryLoadAsync(CancellationToken.None);
|
||||||
|
Assert.NotNull(persisted);
|
||||||
|
Assert.Equal(99, Assert.Single(persisted.Hierarchy).GobjectId);
|
||||||
|
Assert.Equal("PV", Assert.Single(persisted.Attributes).AttributeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that when the Galaxy database is unreachable on first refresh but a
|
||||||
|
/// snapshot exists on disk, the cache serves that data with <c>Stale</c> status
|
||||||
|
/// rather than coming up empty.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshAsync_WhenDatabaseUnreachableButSnapshotOnDisk_RestoresStaleData()
|
||||||
|
{
|
||||||
|
GalaxyHierarchySnapshotStore store = CreateStore();
|
||||||
|
await store.SaveAsync(
|
||||||
|
new GalaxyHierarchySnapshot(
|
||||||
|
LastDeployTime: new DateTimeOffset(2026, 5, 20, 9, 0, 0, TimeSpan.Zero),
|
||||||
|
SavedAt: new DateTimeOffset(2026, 5, 20, 9, 1, 0, TimeSpan.Zero),
|
||||||
|
Hierarchy: [SampleHierarchyRow()],
|
||||||
|
Attributes: [SampleAttributeRow()]),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
GalaxyDeployNotifier notifier = new();
|
||||||
|
ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable"));
|
||||||
|
GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store);
|
||||||
|
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status);
|
||||||
|
Assert.True(cache.Current.HasData);
|
||||||
|
Assert.Equal(1, cache.Current.ObjectCount);
|
||||||
|
Assert.Equal(1, cache.Current.AttributeCount);
|
||||||
|
Assert.NotNull(notifier.Latest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that when the disk snapshot's deploy time still matches the live
|
||||||
|
/// Galaxy database, the cache promotes the restored data to <c>Healthy</c>
|
||||||
|
/// without re-running the heavy hierarchy and attribute queries.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshAsync_WhenSnapshotDeployMatchesLive_PromotesToHealthyWithoutHeavyQuery()
|
||||||
|
{
|
||||||
|
DateTime deployTime = new(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
GalaxyHierarchySnapshotStore store = CreateStore();
|
||||||
|
await store.SaveAsync(
|
||||||
|
new GalaxyHierarchySnapshot(
|
||||||
|
LastDeployTime: new DateTimeOffset(deployTime, TimeSpan.Zero),
|
||||||
|
SavedAt: new DateTimeOffset(2026, 5, 20, 9, 1, 0, TimeSpan.Zero),
|
||||||
|
Hierarchy: [SampleHierarchyRow()],
|
||||||
|
Attributes: [SampleAttributeRow()]),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
GalaxyDeployNotifier notifier = new();
|
||||||
|
StubGalaxyRepository repository = new(deployTime);
|
||||||
|
GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store);
|
||||||
|
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);
|
||||||
|
Assert.Equal(1, cache.Current.ObjectCount);
|
||||||
|
Assert.Equal(0, repository.GetHierarchyCount);
|
||||||
|
Assert.Equal(0, repository.GetAttributesCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that a restored on-disk snapshot completes the first-load gate
|
||||||
|
/// immediately, so a browse call racing the first refresh is not blocked for
|
||||||
|
/// the full bootstrap budget while the live Galaxy query is still running.
|
||||||
|
/// Regression test for Server-033.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshAsync_RestoredSnapshotCompletesFirstLoadBeforeLiveQueryReturns()
|
||||||
|
{
|
||||||
|
GalaxyHierarchySnapshotStore store = CreateStore();
|
||||||
|
await store.SaveAsync(
|
||||||
|
new GalaxyHierarchySnapshot(
|
||||||
|
LastDeployTime: new DateTimeOffset(2026, 5, 20, 9, 0, 0, TimeSpan.Zero),
|
||||||
|
SavedAt: new DateTimeOffset(2026, 5, 20, 9, 1, 0, TimeSpan.Zero),
|
||||||
|
Hierarchy: [SampleHierarchyRow()],
|
||||||
|
Attributes: [SampleAttributeRow()]),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
GalaxyDeployNotifier notifier = new();
|
||||||
|
BlockingGalaxyRepository repository = new();
|
||||||
|
GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store);
|
||||||
|
|
||||||
|
Task refresh = cache.RefreshAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// The live query is blocked inside the repository; first-load must still
|
||||||
|
// complete — from the restored snapshot — well within the wait budget.
|
||||||
|
await cache.WaitForFirstLoadAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.True(cache.Current.HasData);
|
||||||
|
Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status);
|
||||||
|
|
||||||
|
repository.Release();
|
||||||
|
await refresh.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies a corrupt on-disk snapshot does not crash startup: the cache
|
||||||
|
/// ignores the unreadable file and comes up Unavailable when the database is
|
||||||
|
/// also unreachable. Regression test for Server-037.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshAsync_WhenSnapshotFileCorrupt_ComesUpUnavailableWithoutThrowing()
|
||||||
|
{
|
||||||
|
string path = CreateTempPath();
|
||||||
|
await File.WriteAllTextAsync(path, "{ this is not valid json");
|
||||||
|
GalaxyHierarchySnapshotStore store = CreateStore(path);
|
||||||
|
|
||||||
|
GalaxyDeployNotifier notifier = new();
|
||||||
|
ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable"));
|
||||||
|
GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store);
|
||||||
|
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status);
|
||||||
|
Assert.False(cache.Current.HasData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that with snapshot persistence disabled the cache does not
|
||||||
|
/// restore from disk — an unreachable database leaves it Unavailable.
|
||||||
|
/// Regression test for Server-037.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshAsync_WhenPersistDisabled_DoesNotRestoreFromDisk()
|
||||||
|
{
|
||||||
|
GalaxyHierarchySnapshotStore store = CreateStore(CreateTempPath(), persist: false);
|
||||||
|
|
||||||
|
GalaxyDeployNotifier notifier = new();
|
||||||
|
ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable"));
|
||||||
|
GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store);
|
||||||
|
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status);
|
||||||
|
Assert.False(cache.Current.HasData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that a snapshot save aborted because the gateway is shutting down
|
||||||
|
/// (the refresh token is cancelled) is not logged as a persistence failure.
|
||||||
|
/// Regression test for Server-036.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshAsync_WhenSnapshotSaveCancelledAtShutdown_DoesNotLogPersistFailure()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource cts = new();
|
||||||
|
GalaxyDeployNotifier notifier = new();
|
||||||
|
StubGalaxyRepository repository = new(
|
||||||
|
deployTime: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc),
|
||||||
|
hierarchy: [SampleHierarchyRow()],
|
||||||
|
attributes: [SampleAttributeRow()]);
|
||||||
|
CancellingSaveStore store = new(cts);
|
||||||
|
RecordingLogger<GalaxyHierarchyCache> logger = new();
|
||||||
|
GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), logger, store);
|
||||||
|
|
||||||
|
await cache.RefreshAsync(cts.Token);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
logger.Entries,
|
||||||
|
entry => entry.Level == LogLevel.Warning
|
||||||
|
&& entry.Message.Contains("persist", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GalaxyHierarchyRow SampleHierarchyRow() => new()
|
||||||
|
{
|
||||||
|
GobjectId = 99,
|
||||||
|
TagName = "Pump_001",
|
||||||
|
ContainedName = "Pump",
|
||||||
|
BrowseName = "Pump",
|
||||||
|
CategoryId = 10,
|
||||||
|
TemplateChain = ["AppPump"],
|
||||||
|
};
|
||||||
|
|
||||||
|
private static GalaxyAttributeRow SampleAttributeRow() => new()
|
||||||
|
{
|
||||||
|
GobjectId = 99,
|
||||||
|
TagName = "Pump_001",
|
||||||
|
AttributeName = "PV",
|
||||||
|
FullTagReference = "Pump_001.PV",
|
||||||
|
MxDataType = 5,
|
||||||
|
DataTypeName = "Float",
|
||||||
|
};
|
||||||
|
|
||||||
|
private string CreateTempPath()
|
||||||
|
{
|
||||||
|
string path = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"mxgw-galaxy-cache-test-{Guid.NewGuid():N}.json");
|
||||||
|
_tempPaths.Add(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GalaxyHierarchySnapshotStore CreateStore() => CreateStore(CreateTempPath());
|
||||||
|
|
||||||
|
private static GalaxyHierarchySnapshotStore CreateStore(string path, bool persist = true)
|
||||||
|
{
|
||||||
|
GalaxyRepositoryOptions options = new()
|
||||||
|
{
|
||||||
|
PersistSnapshot = persist,
|
||||||
|
SnapshotCachePath = path,
|
||||||
|
};
|
||||||
|
return new GalaxyHierarchySnapshotStore(Options.Create(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><see cref="IGalaxyRepository"/> whose deploy-time query blocks until released.</summary>
|
||||||
|
private sealed class BlockingGalaxyRepository : IGalaxyRepository
|
||||||
|
{
|
||||||
|
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
public void Release() => _release.TrySetResult();
|
||||||
|
|
||||||
|
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false);
|
||||||
|
|
||||||
|
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _release.Task.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
throw new InvalidOperationException("Galaxy repository unreachable");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||||
|
=> throw new InvalidOperationException("GetHierarchyAsync should not be reached");
|
||||||
|
|
||||||
|
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||||
|
=> throw new InvalidOperationException("GetAttributesAsync should not be reached");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Snapshot store whose <see cref="SaveAsync"/> cancels the token mid-save.</summary>
|
||||||
|
private sealed class CancellingSaveStore(CancellationTokenSource cts) : IGalaxyHierarchySnapshotStore
|
||||||
|
{
|
||||||
|
public Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<GalaxyHierarchySnapshot?>(null);
|
||||||
|
|
||||||
|
public Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cts.Cancel();
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimal <see cref="ILogger{T}"/> that records every emitted log entry.</summary>
|
||||||
|
private sealed class RecordingLogger<T> : ILogger<T>
|
||||||
|
{
|
||||||
|
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||||
|
|
||||||
|
public IDisposable BeginScope<TState>(TState state)
|
||||||
|
where TState : notnull => NullScope.Instance;
|
||||||
|
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel,
|
||||||
|
EventId eventId,
|
||||||
|
TState state,
|
||||||
|
Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
Entries.Add((logLevel, formatter(state, exception)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullScope : IDisposable
|
||||||
|
{
|
||||||
|
public static readonly NullScope Instance = new();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>In-memory <see cref="IGalaxyRepository"/> that returns fixed rowsets.</summary>
|
||||||
|
private sealed class StubGalaxyRepository(
|
||||||
|
DateTime? deployTime,
|
||||||
|
List<GalaxyHierarchyRow>? hierarchy = null,
|
||||||
|
List<GalaxyAttributeRow>? attributes = null) : IGalaxyRepository
|
||||||
|
{
|
||||||
|
private readonly List<GalaxyHierarchyRow> _hierarchy = hierarchy ?? [];
|
||||||
|
private readonly List<GalaxyAttributeRow> _attributes = attributes ?? [];
|
||||||
|
|
||||||
|
public int GetHierarchyCount { get; private set; }
|
||||||
|
|
||||||
|
public int GetAttributesCount { get; private set; }
|
||||||
|
|
||||||
|
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
|
||||||
|
|
||||||
|
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(deployTime);
|
||||||
|
|
||||||
|
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
GetHierarchyCount++;
|
||||||
|
return Task.FromResult(_hierarchy);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
GetAttributesCount++;
|
||||||
|
return Task.FromResult(_attributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (string path in _tempPaths)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
File.Delete(path + ".tmp");
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// Best-effort cleanup of test scratch files.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository
|
private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository
|
||||||
{
|
{
|
||||||
/// <summary>Gets the number of times <see cref="GetLastDeployTimeAsync"/> was called.</summary>
|
/// <summary>Gets the number of times <see cref="GetLastDeployTimeAsync"/> was called.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Server.Galaxy;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Galaxy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers <see cref="GalaxyHierarchySnapshotStore"/>: the on-disk persistence
|
||||||
|
/// that lets the Galaxy browse cache survive a cold start while the Galaxy
|
||||||
|
/// database is unreachable.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<string> _tempPaths = [];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAsync_ThenTryLoadAsync_RoundTripsRows()
|
||||||
|
{
|
||||||
|
string path = CreateTempPath();
|
||||||
|
GalaxyHierarchySnapshotStore store = CreateStore(path);
|
||||||
|
GalaxyHierarchySnapshot snapshot = SampleSnapshot();
|
||||||
|
|
||||||
|
await store.SaveAsync(snapshot, CancellationToken.None);
|
||||||
|
GalaxyHierarchySnapshot? loaded = await store.TryLoadAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal(snapshot.LastDeployTime, loaded.LastDeployTime);
|
||||||
|
Assert.Equal(snapshot.SavedAt, loaded.SavedAt);
|
||||||
|
|
||||||
|
GalaxyHierarchyRow row = Assert.Single(loaded.Hierarchy);
|
||||||
|
Assert.Equal(7, row.GobjectId);
|
||||||
|
Assert.Equal("Pump_001", row.TagName);
|
||||||
|
Assert.Equal(["AppPump", "Pump"], row.TemplateChain);
|
||||||
|
|
||||||
|
Assert.Equal(2, loaded.Attributes.Count);
|
||||||
|
GalaxyAttributeRow withDimension = loaded.Attributes[0];
|
||||||
|
Assert.Equal("PV", withDimension.AttributeName);
|
||||||
|
Assert.Equal(8, withDimension.ArrayDimension);
|
||||||
|
Assert.True(withDimension.IsAlarm);
|
||||||
|
Assert.Null(loaded.Attributes[1].ArrayDimension);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryLoadAsync_WhenNoFileExists_ReturnsNull()
|
||||||
|
{
|
||||||
|
GalaxyHierarchySnapshotStore store = CreateStore(CreateTempPath());
|
||||||
|
|
||||||
|
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAsync_WhenPersistenceDisabled_WritesNothing()
|
||||||
|
{
|
||||||
|
string path = CreateTempPath();
|
||||||
|
GalaxyHierarchySnapshotStore store = CreateStore(path, persist: false);
|
||||||
|
|
||||||
|
await store.SaveAsync(SampleSnapshot(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(File.Exists(path));
|
||||||
|
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryLoadAsync_WhenFileIsCorruptJson_ReturnsNull()
|
||||||
|
{
|
||||||
|
string path = CreateTempPath();
|
||||||
|
await File.WriteAllTextAsync(path, "{ this is not valid json");
|
||||||
|
GalaxyHierarchySnapshotStore store = CreateStore(path);
|
||||||
|
|
||||||
|
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryLoadAsync_WhenSchemaVersionUnrecognized_ReturnsNull()
|
||||||
|
{
|
||||||
|
string path = CreateTempPath();
|
||||||
|
await File.WriteAllTextAsync(path, """{"SchemaVersion":999,"Snapshot":null}""");
|
||||||
|
GalaxyHierarchySnapshotStore store = CreateStore(path);
|
||||||
|
|
||||||
|
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAsync_OverwritesAnEarlierSnapshot()
|
||||||
|
{
|
||||||
|
string path = CreateTempPath();
|
||||||
|
GalaxyHierarchySnapshotStore store = CreateStore(path);
|
||||||
|
|
||||||
|
await store.SaveAsync(SampleSnapshot(), CancellationToken.None);
|
||||||
|
GalaxyHierarchySnapshot second = SampleSnapshot() with
|
||||||
|
{
|
||||||
|
Hierarchy = [],
|
||||||
|
Attributes = [],
|
||||||
|
};
|
||||||
|
await store.SaveAsync(second, CancellationToken.None);
|
||||||
|
|
||||||
|
GalaxyHierarchySnapshot? loaded = await store.TryLoadAsync(CancellationToken.None);
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Empty(loaded.Hierarchy);
|
||||||
|
Assert.Empty(loaded.Attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GalaxyHierarchySnapshot SampleSnapshot() => new(
|
||||||
|
LastDeployTime: new DateTimeOffset(2026, 5, 20, 9, 30, 0, TimeSpan.Zero),
|
||||||
|
SavedAt: new DateTimeOffset(2026, 5, 20, 9, 31, 0, TimeSpan.Zero),
|
||||||
|
Hierarchy:
|
||||||
|
[
|
||||||
|
new GalaxyHierarchyRow
|
||||||
|
{
|
||||||
|
GobjectId = 7,
|
||||||
|
TagName = "Pump_001",
|
||||||
|
ContainedName = "Pump",
|
||||||
|
BrowseName = "Pump",
|
||||||
|
CategoryId = 10,
|
||||||
|
TemplateChain = ["AppPump", "Pump"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Attributes:
|
||||||
|
[
|
||||||
|
new GalaxyAttributeRow
|
||||||
|
{
|
||||||
|
GobjectId = 7,
|
||||||
|
TagName = "Pump_001",
|
||||||
|
AttributeName = "PV",
|
||||||
|
FullTagReference = "Pump_001.PV[]",
|
||||||
|
MxDataType = 5,
|
||||||
|
DataTypeName = "Float",
|
||||||
|
IsArray = true,
|
||||||
|
ArrayDimension = 8,
|
||||||
|
IsAlarm = true,
|
||||||
|
},
|
||||||
|
new GalaxyAttributeRow
|
||||||
|
{
|
||||||
|
GobjectId = 7,
|
||||||
|
TagName = "Pump_001",
|
||||||
|
AttributeName = "Mode",
|
||||||
|
FullTagReference = "Pump_001.Mode",
|
||||||
|
MxDataType = 3,
|
||||||
|
DataTypeName = "Integer",
|
||||||
|
ArrayDimension = null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
private static GalaxyHierarchySnapshotStore CreateStore(string path, bool persist = true)
|
||||||
|
{
|
||||||
|
GalaxyRepositoryOptions options = new()
|
||||||
|
{
|
||||||
|
PersistSnapshot = persist,
|
||||||
|
SnapshotCachePath = path,
|
||||||
|
};
|
||||||
|
return new GalaxyHierarchySnapshotStore(Options.Create(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CreateTempPath()
|
||||||
|
{
|
||||||
|
string path = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"mxgw-galaxy-snapshot-{Guid.NewGuid():N}.json");
|
||||||
|
_tempPaths.Add(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (string path in _tempPaths)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
File.Delete(path + ".tmp");
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// Best-effort cleanup of test scratch files.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,4 +126,17 @@ public sealed class DashboardBrowseAndAlarmModelTests
|
|||||||
Assert.True(unackedRow.IsUnacknowledged);
|
Assert.True(unackedRow.IsUnacknowledged);
|
||||||
Assert.False(ackedRow.IsUnacknowledged);
|
Assert.False(ackedRow.IsUnacknowledged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatValue_AndDataType_RenderArrayElementsAndElementType()
|
||||||
|
{
|
||||||
|
MxArray array = new() { ElementDataType = MxDataType.Double };
|
||||||
|
array.Dimensions.Add(3u);
|
||||||
|
array.DoubleValues = new DoubleArray();
|
||||||
|
array.DoubleValues.Values.Add(new[] { 1.5, 2.25, 3.0 });
|
||||||
|
MxValue value = new() { ArrayValue = array };
|
||||||
|
|
||||||
|
Assert.Equal("[1.5, 2.25, 3]", DashboardMxValueFormatter.FormatValue(value));
|
||||||
|
Assert.Equal("Double[3]", DashboardMxValueFormatter.FormatDataType(value));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,8 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
|
|||||||
mapper,
|
mapper,
|
||||||
eventStreamService,
|
eventStreamService,
|
||||||
_metrics,
|
_metrics,
|
||||||
NullLogger<MxAccessGatewayService>.Instance);
|
NullLogger<MxAccessGatewayService>.Instance,
|
||||||
|
new FakeGatewayAlarmService());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -612,7 +612,8 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
|||||||
new MxAccessGrpcMapper(),
|
new MxAccessGrpcMapper(),
|
||||||
new FakeEventStreamService(sessionManager),
|
new FakeEventStreamService(sessionManager),
|
||||||
new GatewayMetrics(),
|
new GatewayMetrics(),
|
||||||
NullLogger<MxAccessGatewayService>.Instance);
|
NullLogger<MxAccessGatewayService>.Instance,
|
||||||
|
new FakeGatewayAlarmService());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FakeSessionManager CreateSessionManagerWithSeed()
|
private static FakeSessionManager CreateSessionManagerWithSeed()
|
||||||
|
|||||||
@@ -92,54 +92,6 @@ public sealed class MxAccessGatewayServiceTests
|
|||||||
Assert.Equal(1, sessionManager.InvokeCount);
|
Assert.Equal(1, sessionManager.InvokeCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that AcknowledgeAlarm maps a genuinely missing session to NotFound via
|
|
||||||
/// the service's own <c>ResolveSession</c> lookup rather than an injected exception.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task AcknowledgeAlarm_WhenSessionMissing_ThrowsNotFound()
|
|
||||||
{
|
|
||||||
FakeSessionManager sessionManager = new() { ResolveOnlySeededSessions = true };
|
|
||||||
MxAccessGatewayService service = CreateService(sessionManager);
|
|
||||||
|
|
||||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
|
||||||
async () => await service.AcknowledgeAlarm(
|
|
||||||
new AcknowledgeAlarmRequest
|
|
||||||
{
|
|
||||||
SessionId = "session-missing",
|
|
||||||
AlarmFullReference = "Tank01.Level.HiHi",
|
|
||||||
OperatorUser = "alice",
|
|
||||||
},
|
|
||||||
new TestServerCallContext()));
|
|
||||||
|
|
||||||
Assert.Equal(StatusCode.NotFound, exception.StatusCode);
|
|
||||||
Assert.Contains("session-missing", exception.Status.Detail, StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that QueryActiveAlarms maps a genuinely missing session to NotFound via
|
|
||||||
/// the service's own <c>ResolveSession</c> lookup rather than an injected exception.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task QueryActiveAlarms_WhenSessionMissing_ThrowsNotFound()
|
|
||||||
{
|
|
||||||
FakeSessionManager sessionManager = new() { ResolveOnlySeededSessions = true };
|
|
||||||
MxAccessGatewayService service = CreateService(sessionManager);
|
|
||||||
|
|
||||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
|
||||||
async () => await service.QueryActiveAlarms(
|
|
||||||
new QueryActiveAlarmsRequest
|
|
||||||
{
|
|
||||||
SessionId = "session-missing",
|
|
||||||
AlarmFilterPrefix = "Tank01.",
|
|
||||||
},
|
|
||||||
new RecordingServerStreamWriter<ActiveAlarmSnapshot>(),
|
|
||||||
new TestServerCallContext()));
|
|
||||||
|
|
||||||
Assert.Equal(StatusCode.NotFound, exception.StatusCode);
|
|
||||||
Assert.Contains("session-missing", exception.Status.Detail, StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that Invoke throws InvalidArgument and does not invoke the session manager when payload is mismatched.</summary>
|
/// <summary>Verifies that Invoke throws InvalidArgument and does not invoke the session manager when payload is mismatched.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Invoke_WithMismatchedPayload_ThrowsInvalidArgumentAndDoesNotCallSessionManager()
|
public async Task Invoke_WithMismatchedPayload_ThrowsInvalidArgumentAndDoesNotCallSessionManager()
|
||||||
@@ -301,32 +253,13 @@ public sealed class MxAccessGatewayServiceTests
|
|||||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== PR A.4 — AcknowledgeAlarm + QueryActiveAlarms handler contract =====
|
// ===== AcknowledgeAlarm + StreamAlarms handler contract =====
|
||||||
//
|
//
|
||||||
// Worker-side dispatch (translating AcknowledgeAlarm to MxAccess Acknowledge,
|
// AcknowledgeAlarm validates alarm_full_reference then delegates to the
|
||||||
// walking the active-alarm collection for QueryActiveAlarms) is gated on PR
|
// session-less IGatewayAlarmService; StreamAlarms forwards the central
|
||||||
// A.2's dev-rig validation. These tests pin the public surface so the worker
|
// alarm feed. CreateService injects FakeGatewayAlarmService.
|
||||||
// wiring lands without changing observable behaviour for clients.
|
|
||||||
|
|
||||||
/// <summary>Verifies AcknowledgeAlarm rejects empty session_id.</summary>
|
/// <summary>Verifies AcknowledgeAlarm rejects an empty alarm_full_reference.</summary>
|
||||||
[Fact]
|
|
||||||
public async Task AcknowledgeAlarm_WithMissingSessionId_ThrowsInvalidArgument()
|
|
||||||
{
|
|
||||||
MxAccessGatewayService service = CreateService(new FakeSessionManager());
|
|
||||||
|
|
||||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
|
||||||
async () => await service.AcknowledgeAlarm(
|
|
||||||
new AcknowledgeAlarmRequest
|
|
||||||
{
|
|
||||||
AlarmFullReference = "Tank01.Level.HiHi",
|
|
||||||
OperatorUser = "alice",
|
|
||||||
},
|
|
||||||
new TestServerCallContext()));
|
|
||||||
|
|
||||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies AcknowledgeAlarm rejects empty alarm_full_reference.</summary>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgeAlarm_WithMissingAlarmReference_ThrowsInvalidArgument()
|
public async Task AcknowledgeAlarm_WithMissingAlarmReference_ThrowsInvalidArgument()
|
||||||
{
|
{
|
||||||
@@ -334,71 +267,47 @@ public sealed class MxAccessGatewayServiceTests
|
|||||||
|
|
||||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
async () => await service.AcknowledgeAlarm(
|
async () => await service.AcknowledgeAlarm(
|
||||||
new AcknowledgeAlarmRequest
|
new AcknowledgeAlarmRequest { OperatorUser = "alice" },
|
||||||
{
|
|
||||||
SessionId = "session-1",
|
|
||||||
OperatorUser = "alice",
|
|
||||||
},
|
|
||||||
new TestServerCallContext()));
|
new TestServerCallContext()));
|
||||||
|
|
||||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies AcknowledgeAlarm returns OK with a "dispatcher not registered" diagnostic when DI omits the dispatcher.</summary>
|
/// <summary>Verifies AcknowledgeAlarm delegates a valid request to the alarm service.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgeAlarm_WithValidRequest_ReturnsOkWithNotRegisteredDiagnostic()
|
public async Task AcknowledgeAlarm_WithValidRequest_DelegatesToAlarmService()
|
||||||
{
|
{
|
||||||
MxAccessGatewayService service = CreateService(new FakeSessionManager());
|
MxAccessGatewayService service = CreateService(new FakeSessionManager());
|
||||||
|
|
||||||
AcknowledgeAlarmReply reply = await service.AcknowledgeAlarm(
|
AcknowledgeAlarmReply reply = await service.AcknowledgeAlarm(
|
||||||
new AcknowledgeAlarmRequest
|
new AcknowledgeAlarmRequest
|
||||||
{
|
{
|
||||||
SessionId = "session-1",
|
|
||||||
ClientCorrelationId = "corr-1",
|
ClientCorrelationId = "corr-1",
|
||||||
AlarmFullReference = "Tank01.Level.HiHi",
|
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
||||||
Comment = "investigating",
|
Comment = "investigating",
|
||||||
OperatorUser = "alice",
|
OperatorUser = "alice",
|
||||||
},
|
},
|
||||||
new TestServerCallContext());
|
new TestServerCallContext());
|
||||||
|
|
||||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
Assert.Equal("session-1", reply.SessionId);
|
|
||||||
Assert.Equal("corr-1", reply.CorrelationId);
|
Assert.Equal("corr-1", reply.CorrelationId);
|
||||||
Assert.Contains("not registered", reply.DiagnosticMessage, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies QueryActiveAlarms rejects empty session_id.</summary>
|
/// <summary>Verifies StreamAlarms forwards the central alarm feed, ending with snapshot_complete.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryActiveAlarms_WithMissingSessionId_ThrowsInvalidArgument()
|
public async Task StreamAlarms_ForwardsTheCentralAlarmFeed()
|
||||||
{
|
{
|
||||||
MxAccessGatewayService service = CreateService(new FakeSessionManager());
|
MxAccessGatewayService service = CreateService(new FakeSessionManager());
|
||||||
|
RecordingServerStreamWriter<AlarmFeedMessage> sink = new();
|
||||||
|
|
||||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
await service.StreamAlarms(
|
||||||
async () => await service.QueryActiveAlarms(
|
new StreamAlarmsRequest(),
|
||||||
new QueryActiveAlarmsRequest(),
|
|
||||||
new RecordingServerStreamWriter<ActiveAlarmSnapshot>(),
|
|
||||||
new TestServerCallContext()));
|
|
||||||
|
|
||||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies QueryActiveAlarms streams zero snapshots until PR A.2 wires the worker walk.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task QueryActiveAlarms_WithValidRequest_StreamsZeroSnapshots()
|
|
||||||
{
|
|
||||||
MxAccessGatewayService service = CreateService(new FakeSessionManager());
|
|
||||||
RecordingServerStreamWriter<ActiveAlarmSnapshot> sink = new();
|
|
||||||
|
|
||||||
await service.QueryActiveAlarms(
|
|
||||||
new QueryActiveAlarmsRequest
|
|
||||||
{
|
|
||||||
SessionId = "session-1",
|
|
||||||
AlarmFilterPrefix = "Tank01.",
|
|
||||||
},
|
|
||||||
sink,
|
sink,
|
||||||
new TestServerCallContext());
|
new TestServerCallContext());
|
||||||
|
|
||||||
Assert.Empty(sink.Messages);
|
Assert.Contains(
|
||||||
|
sink.Messages,
|
||||||
|
message => message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.SnapshotComplete);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies OpenSession advertises the alarm RPC capability strings.</summary>
|
/// <summary>Verifies OpenSession advertises the alarm RPC capability strings.</summary>
|
||||||
@@ -433,7 +342,8 @@ public sealed class MxAccessGatewayServiceTests
|
|||||||
new MxAccessGrpcMapper(),
|
new MxAccessGrpcMapper(),
|
||||||
new FakeEventStreamService(sessionManager),
|
new FakeEventStreamService(sessionManager),
|
||||||
metrics ?? new GatewayMetrics(),
|
metrics ?? new GatewayMetrics(),
|
||||||
NullLogger<MxAccessGatewayService>.Instance);
|
NullLogger<MxAccessGatewayService>.Instance,
|
||||||
|
new FakeGatewayAlarmService());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ApiKeyIdentity CreateIdentity()
|
private static ApiKeyIdentity CreateIdentity()
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
using MxGateway.Contracts.Proto;
|
|
||||||
using MxGateway.Server.Sessions;
|
|
||||||
|
|
||||||
namespace MxGateway.Tests.Gateway.Sessions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pins the null-fallback dispatcher's behaviour: AcknowledgeAsync
|
|
||||||
/// returns OK with a "dispatcher not registered" diagnostic and
|
|
||||||
/// QueryActiveAlarmsAsync yields an empty stream. Production binds
|
|
||||||
/// <c>WorkerAlarmRpcDispatcher</c> in DI; this fallback is only used
|
|
||||||
/// when no dispatcher is registered (DI omission / standalone tests).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class NotWiredAlarmRpcDispatcherTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task AcknowledgeAsync_WhenNotWired_ReturnsOkWithNotRegisteredDiagnostic()
|
|
||||||
{
|
|
||||||
IAlarmRpcDispatcher dispatcher = new NotWiredAlarmRpcDispatcher();
|
|
||||||
|
|
||||||
AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync(
|
|
||||||
new AcknowledgeAlarmRequest
|
|
||||||
{
|
|
||||||
SessionId = "session-1",
|
|
||||||
ClientCorrelationId = "corr-1",
|
|
||||||
AlarmFullReference = "Tank01.Level.HiHi",
|
|
||||||
Comment = "investigating",
|
|
||||||
OperatorUser = "alice",
|
|
||||||
},
|
|
||||||
CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
|
||||||
Assert.Equal("session-1", reply.SessionId);
|
|
||||||
Assert.Equal("corr-1", reply.CorrelationId);
|
|
||||||
Assert.Contains("not registered", reply.DiagnosticMessage, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task QueryActiveAlarmsAsync_WhenNotWired_YieldsNoSnapshots()
|
|
||||||
{
|
|
||||||
IAlarmRpcDispatcher dispatcher = new NotWiredAlarmRpcDispatcher();
|
|
||||||
|
|
||||||
int count = 0;
|
|
||||||
await foreach (ActiveAlarmSnapshot _ in dispatcher.QueryActiveAlarmsAsync(
|
|
||||||
new QueryActiveAlarmsRequest { SessionId = "session-1" },
|
|
||||||
CancellationToken.None))
|
|
||||||
{
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert.Equal(0, count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
using System.Runtime.CompilerServices;
|
|
||||||
using Google.Protobuf.WellKnownTypes;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using MxGateway.Contracts.Proto;
|
|
||||||
using MxGateway.Server.Configuration;
|
|
||||||
using MxGateway.Server.Metrics;
|
|
||||||
using MxGateway.Server.Sessions;
|
|
||||||
using MxGateway.Server.Workers;
|
|
||||||
|
|
||||||
namespace MxGateway.Tests.Gateway.Sessions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pins the alarm auto-subscribe hook on session open. Runs in
|
|
||||||
/// its own file because the cases are orthogonal to
|
|
||||||
/// <see cref="SessionManagerTests"/> (alarms-disabled vs.
|
|
||||||
/// alarms-enabled lanes), and the fake worker client below verifies
|
|
||||||
/// the issued <c>SubscribeAlarms</c> command shape directly.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class SessionManagerAlarmAutoSubscribeTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task OpenSessionAsync_DoesNotAutoSubscribe_WhenAlarmsDisabled()
|
|
||||||
{
|
|
||||||
AlarmAutoSubscribeWorkerClient worker = new();
|
|
||||||
SessionManager manager = NewManager(worker, alarms: new AlarmsOptions { Enabled = false });
|
|
||||||
|
|
||||||
await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(0, worker.SubscribeAlarmsInvokeCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task OpenSessionAsync_AutoSubscribes_WhenEnabledWithExpression()
|
|
||||||
{
|
|
||||||
AlarmAutoSubscribeWorkerClient worker = new();
|
|
||||||
SessionManager manager = NewManager(worker, alarms: new AlarmsOptions
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
SubscriptionExpression = @"\\HOST\Galaxy!Area1",
|
|
||||||
});
|
|
||||||
|
|
||||||
GatewaySession session = await manager.OpenSessionAsync(
|
|
||||||
CreateOpenRequest(), "client-1", CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(SessionState.Ready, session.State);
|
|
||||||
Assert.Equal(1, worker.SubscribeAlarmsInvokeCount);
|
|
||||||
Assert.Equal(@"\\HOST\Galaxy!Area1",
|
|
||||||
worker.LastSubscribeAlarmsCommand!.SubscriptionExpression);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task OpenSessionAsync_FallsBackToDefaultArea_WhenExpressionEmpty()
|
|
||||||
{
|
|
||||||
AlarmAutoSubscribeWorkerClient worker = new();
|
|
||||||
SessionManager manager = NewManager(worker, alarms: new AlarmsOptions
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
DefaultArea = "DEV",
|
|
||||||
});
|
|
||||||
|
|
||||||
await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(1, worker.SubscribeAlarmsInvokeCount);
|
|
||||||
Assert.Contains(@"\Galaxy!DEV",
|
|
||||||
worker.LastSubscribeAlarmsCommand!.SubscriptionExpression);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task OpenSessionAsync_Succeeds_WhenAutoSubscribeFailsWithRequireOff()
|
|
||||||
{
|
|
||||||
// Worker rejects the SubscribeAlarms command. With RequireSubscribeOnOpen=false
|
|
||||||
// (the default), the session still opens — alarm-side commands later return
|
|
||||||
// "not subscribed", but data subscriptions work.
|
|
||||||
AlarmAutoSubscribeWorkerClient worker = new()
|
|
||||||
{
|
|
||||||
SubscribeAlarmsReplyFactory = _ => new MxCommandReply
|
|
||||||
{
|
|
||||||
Kind = MxCommandKind.SubscribeAlarms,
|
|
||||||
ProtocolStatus = new ProtocolStatus
|
|
||||||
{
|
|
||||||
Code = ProtocolStatusCode.MxaccessFailure,
|
|
||||||
Message = "wnwrap subscribe failed",
|
|
||||||
},
|
|
||||||
DiagnosticMessage = "alarm provider unavailable",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
SessionManager manager = NewManager(worker, alarms: new AlarmsOptions
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
SubscriptionExpression = @"\\HOST\Galaxy!Area1",
|
|
||||||
RequireSubscribeOnOpen = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
GatewaySession session = await manager.OpenSessionAsync(
|
|
||||||
CreateOpenRequest(), "client-1", CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(SessionState.Ready, session.State);
|
|
||||||
Assert.Equal(1, worker.SubscribeAlarmsInvokeCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task OpenSessionAsync_Throws_WhenAutoSubscribeFailsWithRequireOn()
|
|
||||||
{
|
|
||||||
AlarmAutoSubscribeWorkerClient worker = new()
|
|
||||||
{
|
|
||||||
SubscribeAlarmsReplyFactory = _ => new MxCommandReply
|
|
||||||
{
|
|
||||||
Kind = MxCommandKind.SubscribeAlarms,
|
|
||||||
ProtocolStatus = new ProtocolStatus
|
|
||||||
{
|
|
||||||
Code = ProtocolStatusCode.MxaccessFailure,
|
|
||||||
Message = "wnwrap subscribe failed",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
SessionManager manager = NewManager(worker, alarms: new AlarmsOptions
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
SubscriptionExpression = @"\\HOST\Galaxy!Area1",
|
|
||||||
RequireSubscribeOnOpen = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<SessionManagerException>(
|
|
||||||
async () => await manager.OpenSessionAsync(
|
|
||||||
CreateOpenRequest(), "client-1", CancellationToken.None));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Server-006 regression: when auto-subscribe throws after
|
|
||||||
/// <c>SessionOpened()</c> incremented the open-session gauge, the failed
|
|
||||||
/// open must not leave <c>mxgateway.sessions.open</c> over-counted.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task OpenSessionAsync_DoesNotLeakOpenSessionGauge_WhenAutoSubscribeFailsWithRequireOn()
|
|
||||||
{
|
|
||||||
AlarmAutoSubscribeWorkerClient worker = new()
|
|
||||||
{
|
|
||||||
SubscribeAlarmsReplyFactory = _ => new MxCommandReply
|
|
||||||
{
|
|
||||||
Kind = MxCommandKind.SubscribeAlarms,
|
|
||||||
ProtocolStatus = new ProtocolStatus
|
|
||||||
{
|
|
||||||
Code = ProtocolStatusCode.MxaccessFailure,
|
|
||||||
Message = "wnwrap subscribe failed",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
using GatewayMetrics metrics = new();
|
|
||||||
SessionManager manager = NewManager(
|
|
||||||
worker,
|
|
||||||
alarms: new AlarmsOptions
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
SubscriptionExpression = @"\\HOST\Galaxy!Area1",
|
|
||||||
RequireSubscribeOnOpen = true,
|
|
||||||
},
|
|
||||||
metrics: metrics);
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<SessionManagerException>(
|
|
||||||
async () => await manager.OpenSessionAsync(
|
|
||||||
CreateOpenRequest(), "client-1", CancellationToken.None));
|
|
||||||
|
|
||||||
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task OpenSessionAsync_Throws_WhenEnabledButNoExpressionAndRequireOn()
|
|
||||||
{
|
|
||||||
AlarmAutoSubscribeWorkerClient worker = new();
|
|
||||||
SessionManager manager = NewManager(worker, alarms: new AlarmsOptions
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
// No SubscriptionExpression and no DefaultArea.
|
|
||||||
RequireSubscribeOnOpen = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<SessionManagerException>(
|
|
||||||
async () => await manager.OpenSessionAsync(
|
|
||||||
CreateOpenRequest(), "client-1", CancellationToken.None));
|
|
||||||
Assert.Equal(0, worker.SubscribeAlarmsInvokeCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task OpenSessionAsync_Succeeds_WhenEnabledButNoExpressionAndRequireOff()
|
|
||||||
{
|
|
||||||
AlarmAutoSubscribeWorkerClient worker = new();
|
|
||||||
SessionManager manager = NewManager(worker, alarms: new AlarmsOptions
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
// No SubscriptionExpression and no DefaultArea — default require=false.
|
|
||||||
});
|
|
||||||
|
|
||||||
GatewaySession session = await manager.OpenSessionAsync(
|
|
||||||
CreateOpenRequest(), "client-1", CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(SessionState.Ready, session.State);
|
|
||||||
Assert.Equal(0, worker.SubscribeAlarmsInvokeCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SessionManager NewManager(
|
|
||||||
AlarmAutoSubscribeWorkerClient worker,
|
|
||||||
AlarmsOptions alarms,
|
|
||||||
GatewayMetrics? metrics = null)
|
|
||||||
{
|
|
||||||
FakeSessionWorkerClientFactory factory = new(worker);
|
|
||||||
GatewayOptions options = new GatewayOptions
|
|
||||||
{
|
|
||||||
Sessions = new SessionOptions
|
|
||||||
{
|
|
||||||
DefaultCommandTimeoutSeconds = 30,
|
|
||||||
MaxSessions = 64,
|
|
||||||
DefaultLeaseSeconds = 1800,
|
|
||||||
},
|
|
||||||
Worker = new WorkerOptions
|
|
||||||
{
|
|
||||||
StartupTimeoutSeconds = 30,
|
|
||||||
ShutdownTimeoutSeconds = 10,
|
|
||||||
},
|
|
||||||
Alarms = alarms,
|
|
||||||
};
|
|
||||||
return new SessionManager(
|
|
||||||
new SessionRegistry(),
|
|
||||||
factory,
|
|
||||||
Options.Create(options),
|
|
||||||
metrics ?? new GatewayMetrics());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SessionOpenRequest CreateOpenRequest()
|
|
||||||
{
|
|
||||||
return new SessionOpenRequest(
|
|
||||||
RequestedBackend: null,
|
|
||||||
ClientSessionName: "test-session",
|
|
||||||
ClientCorrelationId: "client-correlation-1",
|
|
||||||
CommandTimeout: Duration.FromTimeSpan(TimeSpan.FromSeconds(5)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class FakeSessionWorkerClientFactory(IWorkerClient client) : ISessionWorkerClientFactory
|
|
||||||
{
|
|
||||||
public Task<IWorkerClient> CreateAsync(
|
|
||||||
GatewaySession session,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return Task.FromResult(client);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class AlarmAutoSubscribeWorkerClient : IWorkerClient
|
|
||||||
{
|
|
||||||
public string SessionId { get; } = "session-1";
|
|
||||||
public int? ProcessId { get; } = 1234;
|
|
||||||
public WorkerClientState State { get; set; } = WorkerClientState.Ready;
|
|
||||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
|
||||||
|
|
||||||
public int SubscribeAlarmsInvokeCount { get; private set; }
|
|
||||||
public SubscribeAlarmsCommand? LastSubscribeAlarmsCommand { get; private set; }
|
|
||||||
public Func<WorkerCommand, MxCommandReply>? SubscribeAlarmsReplyFactory { get; init; }
|
|
||||||
|
|
||||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
||||||
|
|
||||||
public Task<WorkerCommandReply> InvokeAsync(
|
|
||||||
WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (command.Command?.Kind == MxCommandKind.SubscribeAlarms)
|
|
||||||
{
|
|
||||||
SubscribeAlarmsInvokeCount++;
|
|
||||||
LastSubscribeAlarmsCommand = command.Command.SubscribeAlarms;
|
|
||||||
MxCommandReply reply = SubscribeAlarmsReplyFactory?.Invoke(command)
|
|
||||||
?? new MxCommandReply
|
|
||||||
{
|
|
||||||
Kind = MxCommandKind.SubscribeAlarms,
|
|
||||||
ProtocolStatus = new ProtocolStatus
|
|
||||||
{
|
|
||||||
Code = ProtocolStatusCode.Ok,
|
|
||||||
Message = "OK",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return Task.FromResult(new WorkerCommandReply { Reply = reply });
|
|
||||||
}
|
|
||||||
return Task.FromResult(new WorkerCommandReply
|
|
||||||
{
|
|
||||||
Reply = new MxCommandReply
|
|
||||||
{
|
|
||||||
Kind = command.Command?.Kind ?? MxCommandKind.Unspecified,
|
|
||||||
ProtocolStatus = new ProtocolStatus
|
|
||||||
{
|
|
||||||
Code = ProtocolStatusCode.Ok,
|
|
||||||
Message = "OK",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await Task.CompletedTask;
|
|
||||||
yield break;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
|
|
||||||
=> Task.CompletedTask;
|
|
||||||
public void Kill(string reason) { }
|
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,393 +0,0 @@
|
|||||||
using System.Runtime.CompilerServices;
|
|
||||||
using MxGateway.Contracts.Proto;
|
|
||||||
using MxGateway.Server.Sessions;
|
|
||||||
using MxGateway.Server.Workers;
|
|
||||||
|
|
||||||
namespace MxGateway.Tests.Gateway.Sessions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pins the production <see cref="WorkerAlarmRpcDispatcher"/>'s behaviour:
|
|
||||||
/// resolves the session by id, issues the matching MxCommand over the
|
|
||||||
/// worker pipe, and unwraps the reply into AcknowledgeAlarmReply or the
|
|
||||||
/// ActiveAlarmSnapshot stream.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class WorkerAlarmRpcDispatcherTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task AcknowledgeAsync_WhenSessionMissing_ReturnsSessionNotFound()
|
|
||||||
{
|
|
||||||
SessionRegistry registry = new();
|
|
||||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
|
||||||
|
|
||||||
AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync(
|
|
||||||
new AcknowledgeAlarmRequest
|
|
||||||
{
|
|
||||||
SessionId = "missing",
|
|
||||||
ClientCorrelationId = "c1",
|
|
||||||
AlarmFullReference = Guid.NewGuid().ToString(),
|
|
||||||
},
|
|
||||||
CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(ProtocolStatusCode.SessionNotFound, reply.ProtocolStatus.Code);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AcknowledgeAsync_WithGuidReference_ForwardsGuidAndReturnsNativeStatus()
|
|
||||||
{
|
|
||||||
SessionRegistry registry = new();
|
|
||||||
Guid alarmGuid = Guid.NewGuid();
|
|
||||||
FakeAlarmWorkerClient worker = new()
|
|
||||||
{
|
|
||||||
ReplyFactory = command =>
|
|
||||||
{
|
|
||||||
Assert.Equal(MxCommandKind.AcknowledgeAlarm, command.Command.Kind);
|
|
||||||
Assert.Equal(alarmGuid.ToString(), command.Command.AcknowledgeAlarmCommand.AlarmGuid);
|
|
||||||
Assert.Equal("ack", command.Command.AcknowledgeAlarmCommand.Comment);
|
|
||||||
Assert.Equal("alice", command.Command.AcknowledgeAlarmCommand.OperatorUser);
|
|
||||||
return new MxCommandReply
|
|
||||||
{
|
|
||||||
Kind = MxCommandKind.AcknowledgeAlarm,
|
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok, Message = "OK" },
|
|
||||||
Hresult = 0,
|
|
||||||
AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = 0 },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
GatewaySession session = NewSession("s1");
|
|
||||||
session.AttachWorkerClient(worker);
|
|
||||||
session.MarkReady();
|
|
||||||
registry.TryAdd(session);
|
|
||||||
|
|
||||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
|
||||||
|
|
||||||
AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync(
|
|
||||||
new AcknowledgeAlarmRequest
|
|
||||||
{
|
|
||||||
SessionId = "s1",
|
|
||||||
ClientCorrelationId = "c1",
|
|
||||||
AlarmFullReference = alarmGuid.ToString(),
|
|
||||||
Comment = "ack",
|
|
||||||
OperatorUser = "alice",
|
|
||||||
},
|
|
||||||
CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
|
||||||
Assert.Equal(0, reply.Hresult);
|
|
||||||
Assert.Equal("s1", reply.SessionId);
|
|
||||||
Assert.Equal("c1", reply.CorrelationId);
|
|
||||||
Assert.Equal(1, worker.InvokeCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AcknowledgeAsync_WhenWorkerFails_PropagatesWorkerDiagnostic()
|
|
||||||
{
|
|
||||||
SessionRegistry registry = new();
|
|
||||||
FakeAlarmWorkerClient worker = new()
|
|
||||||
{
|
|
||||||
ReplyFactory = _ => new MxCommandReply
|
|
||||||
{
|
|
||||||
Kind = MxCommandKind.AcknowledgeAlarm,
|
|
||||||
ProtocolStatus = new ProtocolStatus
|
|
||||||
{
|
|
||||||
Code = ProtocolStatusCode.MxaccessFailure,
|
|
||||||
Message = "AVEVA Acknowledge failed.",
|
|
||||||
},
|
|
||||||
Hresult = -123,
|
|
||||||
DiagnosticMessage = "AVEVA AlarmAckByGUID returned non-zero status -123.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
GatewaySession session = NewSession("s1");
|
|
||||||
session.AttachWorkerClient(worker);
|
|
||||||
session.MarkReady();
|
|
||||||
registry.TryAdd(session);
|
|
||||||
|
|
||||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
|
||||||
|
|
||||||
AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync(
|
|
||||||
new AcknowledgeAlarmRequest
|
|
||||||
{
|
|
||||||
SessionId = "s1",
|
|
||||||
AlarmFullReference = Guid.NewGuid().ToString(),
|
|
||||||
},
|
|
||||||
CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
|
|
||||||
Assert.Equal(-123, reply.Hresult);
|
|
||||||
Assert.Contains("-123", reply.DiagnosticMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("Galaxy!TestArea.TestMachine_001.TestAlarm001", "Galaxy", "TestArea", "TestMachine_001.TestAlarm001")]
|
|
||||||
[InlineData("Galaxy!Area.Tag", "Galaxy", "Area", "Tag")]
|
|
||||||
[InlineData("Provider!Group.Tag.With.Dots", "Provider", "Group", "Tag.With.Dots")]
|
|
||||||
public void TryParseAlarmReference_WithProviderGroupTag_DecomposesParts(
|
|
||||||
string reference, string expectedProvider, string expectedGroup, string expectedName)
|
|
||||||
{
|
|
||||||
Assert.True(WorkerAlarmRpcDispatcher.TryParseAlarmReference(
|
|
||||||
reference, out string provider, out string group, out string name));
|
|
||||||
Assert.Equal(expectedProvider, provider);
|
|
||||||
Assert.Equal(expectedGroup, group);
|
|
||||||
Assert.Equal(expectedName, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("")]
|
|
||||||
[InlineData(" ")]
|
|
||||||
[InlineData(null)]
|
|
||||||
[InlineData("no-bang-here")]
|
|
||||||
[InlineData("!Group.Tag")] // empty provider
|
|
||||||
[InlineData("Galaxy!")] // bang at end
|
|
||||||
[InlineData("Galaxy!Group")] // missing dot
|
|
||||||
[InlineData("Galaxy!.Tag")] // empty group
|
|
||||||
[InlineData("Galaxy!Group.")] // empty tag
|
|
||||||
public void TryParseAlarmReference_WithMalformedReference_ReturnsFalse(string? reference)
|
|
||||||
{
|
|
||||||
Assert.False(WorkerAlarmRpcDispatcher.TryParseAlarmReference(
|
|
||||||
reference, out _, out _, out _));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AcknowledgeAsync_WithProviderGroupTagReference_RoutesViaAckByName()
|
|
||||||
{
|
|
||||||
SessionRegistry registry = new();
|
|
||||||
AcknowledgeAlarmByNameCommand? observed = null;
|
|
||||||
FakeAlarmWorkerClient worker = new()
|
|
||||||
{
|
|
||||||
ReplyFactory = command =>
|
|
||||||
{
|
|
||||||
Assert.Equal(MxCommandKind.AcknowledgeAlarmByName, command.Command.Kind);
|
|
||||||
observed = command.Command.AcknowledgeAlarmByNameCommand;
|
|
||||||
return new MxCommandReply
|
|
||||||
{
|
|
||||||
Kind = MxCommandKind.AcknowledgeAlarmByName,
|
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok, Message = "OK" },
|
|
||||||
Hresult = 0,
|
|
||||||
AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = 0 },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
GatewaySession session = NewSession("s1");
|
|
||||||
session.AttachWorkerClient(worker);
|
|
||||||
session.MarkReady();
|
|
||||||
registry.TryAdd(session);
|
|
||||||
|
|
||||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
|
||||||
|
|
||||||
AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync(
|
|
||||||
new AcknowledgeAlarmRequest
|
|
||||||
{
|
|
||||||
SessionId = "s1",
|
|
||||||
ClientCorrelationId = "c1",
|
|
||||||
AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001",
|
|
||||||
Comment = "ack-by-name",
|
|
||||||
OperatorUser = "bob",
|
|
||||||
},
|
|
||||||
CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
|
||||||
Assert.NotNull(observed);
|
|
||||||
Assert.Equal("TestMachine_001.TestAlarm001", observed!.AlarmName);
|
|
||||||
Assert.Equal("Galaxy", observed.ProviderName);
|
|
||||||
Assert.Equal("TestArea", observed.GroupName);
|
|
||||||
Assert.Equal("bob", observed.OperatorUser);
|
|
||||||
Assert.Equal("ack-by-name", observed.Comment);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AcknowledgeAsync_WithUnparseableReference_ReturnsInvalidRequest()
|
|
||||||
{
|
|
||||||
SessionRegistry registry = new();
|
|
||||||
FakeAlarmWorkerClient worker = new();
|
|
||||||
GatewaySession session = NewSession("s1");
|
|
||||||
session.AttachWorkerClient(worker);
|
|
||||||
session.MarkReady();
|
|
||||||
registry.TryAdd(session);
|
|
||||||
|
|
||||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
|
||||||
|
|
||||||
AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync(
|
|
||||||
new AcknowledgeAlarmRequest
|
|
||||||
{
|
|
||||||
SessionId = "s1",
|
|
||||||
AlarmFullReference = "no-bang-no-dot",
|
|
||||||
},
|
|
||||||
CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
|
|
||||||
Assert.Equal(0, worker.InvokeCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task QueryActiveAlarmsAsync_WithPayloadSnapshots_YieldsEachSnapshot()
|
|
||||||
{
|
|
||||||
SessionRegistry registry = new();
|
|
||||||
FakeAlarmWorkerClient worker = new()
|
|
||||||
{
|
|
||||||
ReplyFactory = command =>
|
|
||||||
{
|
|
||||||
Assert.Equal(MxCommandKind.QueryActiveAlarms, command.Command.Kind);
|
|
||||||
QueryActiveAlarmsReplyPayload payload = new QueryActiveAlarmsReplyPayload();
|
|
||||||
payload.Snapshots.Add(new ActiveAlarmSnapshot
|
|
||||||
{
|
|
||||||
AlarmFullReference = "Galaxy!A.T1",
|
|
||||||
CurrentState = AlarmConditionState.Active,
|
|
||||||
Severity = 500,
|
|
||||||
});
|
|
||||||
payload.Snapshots.Add(new ActiveAlarmSnapshot
|
|
||||||
{
|
|
||||||
AlarmFullReference = "Galaxy!A.T2",
|
|
||||||
CurrentState = AlarmConditionState.ActiveAcked,
|
|
||||||
Severity = 100,
|
|
||||||
});
|
|
||||||
return new MxCommandReply
|
|
||||||
{
|
|
||||||
Kind = MxCommandKind.QueryActiveAlarms,
|
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok, Message = "OK" },
|
|
||||||
QueryActiveAlarms = payload,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
GatewaySession session = NewSession("s1");
|
|
||||||
session.AttachWorkerClient(worker);
|
|
||||||
session.MarkReady();
|
|
||||||
registry.TryAdd(session);
|
|
||||||
|
|
||||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
|
||||||
|
|
||||||
List<ActiveAlarmSnapshot> collected = new();
|
|
||||||
await foreach (ActiveAlarmSnapshot snap in dispatcher.QueryActiveAlarmsAsync(
|
|
||||||
new QueryActiveAlarmsRequest { SessionId = "s1" },
|
|
||||||
CancellationToken.None))
|
|
||||||
{
|
|
||||||
collected.Add(snap);
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert.Equal(2, collected.Count);
|
|
||||||
Assert.Equal("Galaxy!A.T1", collected[0].AlarmFullReference);
|
|
||||||
Assert.Equal("Galaxy!A.T2", collected[1].AlarmFullReference);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Server-019 regression: <c>QueryActiveAlarmsAsync</c> used to silently
|
|
||||||
/// <c>yield break</c> when the session id was not in the registry, while the
|
|
||||||
/// peer <c>AcknowledgeAsync</c> returned <c>SessionNotFound</c>. Both methods
|
|
||||||
/// now signal a missing session — <c>QueryActiveAlarms</c> throws a
|
|
||||||
/// <see cref="SessionManagerException"/> with
|
|
||||||
/// <see cref="SessionManagerErrorCode.SessionNotFound"/> (the gateway gRPC
|
|
||||||
/// layer maps it to gRPC <c>NotFound</c>), aligning the dispatcher's
|
|
||||||
/// missing-session contract across the two RPCs.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task QueryActiveAlarmsAsync_WhenSessionMissing_ThrowsSessionNotFound()
|
|
||||||
{
|
|
||||||
SessionRegistry registry = new();
|
|
||||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
|
||||||
|
|
||||||
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(async () =>
|
|
||||||
{
|
|
||||||
await foreach (ActiveAlarmSnapshot _ in dispatcher.QueryActiveAlarmsAsync(
|
|
||||||
new QueryActiveAlarmsRequest { SessionId = "missing" },
|
|
||||||
CancellationToken.None))
|
|
||||||
{
|
|
||||||
// No yield expected — the throw happens before the first iteration.
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.Equal(SessionManagerErrorCode.SessionNotFound, exception.ErrorCode);
|
|
||||||
|
|
||||||
// Peer-method parity: AcknowledgeAsync still signals SessionNotFound (as an
|
|
||||||
// in-band ProtocolStatus, since it's a unary RPC). The two methods now agree
|
|
||||||
// that a missing session is an error, not an empty success.
|
|
||||||
AcknowledgeAlarmReply ackReply = await dispatcher.AcknowledgeAsync(
|
|
||||||
new AcknowledgeAlarmRequest
|
|
||||||
{
|
|
||||||
SessionId = "missing",
|
|
||||||
AlarmFullReference = Guid.NewGuid().ToString(),
|
|
||||||
},
|
|
||||||
CancellationToken.None);
|
|
||||||
Assert.Equal(ProtocolStatusCode.SessionNotFound, ackReply.ProtocolStatus.Code);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task QueryActiveAlarmsAsync_WhenWorkerFails_YieldsEmpty()
|
|
||||||
{
|
|
||||||
SessionRegistry registry = new();
|
|
||||||
FakeAlarmWorkerClient worker = new()
|
|
||||||
{
|
|
||||||
ReplyFactory = _ => new MxCommandReply
|
|
||||||
{
|
|
||||||
Kind = MxCommandKind.QueryActiveAlarms,
|
|
||||||
ProtocolStatus = new ProtocolStatus
|
|
||||||
{
|
|
||||||
Code = ProtocolStatusCode.MxaccessFailure,
|
|
||||||
Message = "alarm consumer not subscribed",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
GatewaySession session = NewSession("s1");
|
|
||||||
session.AttachWorkerClient(worker);
|
|
||||||
session.MarkReady();
|
|
||||||
registry.TryAdd(session);
|
|
||||||
|
|
||||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
|
||||||
|
|
||||||
List<ActiveAlarmSnapshot> collected = new();
|
|
||||||
await foreach (ActiveAlarmSnapshot snap in dispatcher.QueryActiveAlarmsAsync(
|
|
||||||
new QueryActiveAlarmsRequest { SessionId = "s1" },
|
|
||||||
CancellationToken.None))
|
|
||||||
{
|
|
||||||
collected.Add(snap);
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert.Empty(collected);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static GatewaySession NewSession(string sessionId)
|
|
||||||
{
|
|
||||||
return new(
|
|
||||||
sessionId,
|
|
||||||
"mxaccess",
|
|
||||||
$"mxaccess-gateway-1-{sessionId}",
|
|
||||||
"nonce",
|
|
||||||
"client-1",
|
|
||||||
"test-session",
|
|
||||||
"client-correlation-1",
|
|
||||||
commandTimeout: TimeSpan.FromSeconds(30),
|
|
||||||
startupTimeout: TimeSpan.FromSeconds(5),
|
|
||||||
shutdownTimeout: TimeSpan.FromSeconds(5),
|
|
||||||
leaseDuration: TimeSpan.FromMinutes(30),
|
|
||||||
openedAt: DateTimeOffset.UtcNow);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class FakeAlarmWorkerClient : IWorkerClient
|
|
||||||
{
|
|
||||||
public string SessionId { get; } = "session-1";
|
|
||||||
public int? ProcessId { get; } = 1;
|
|
||||||
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
|
||||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
|
||||||
|
|
||||||
public Func<WorkerCommand, MxCommandReply>? ReplyFactory { get; set; }
|
|
||||||
public int InvokeCount { get; private set; }
|
|
||||||
|
|
||||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
||||||
|
|
||||||
public Task<WorkerCommandReply> InvokeAsync(
|
|
||||||
WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
InvokeCount++;
|
|
||||||
MxCommandReply reply = ReplyFactory?.Invoke(command) ?? new MxCommandReply();
|
|
||||||
return Task.FromResult(new WorkerCommandReply { Reply = reply });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await Task.CompletedTask;
|
|
||||||
yield break;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
|
|
||||||
public void Kill(string reason) { }
|
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -128,6 +128,7 @@ public sealed class WorkerClientTests
|
|||||||
new WorkerClientOptions
|
new WorkerClientOptions
|
||||||
{
|
{
|
||||||
EventChannelCapacity = 1,
|
EventChannelCapacity = 1,
|
||||||
|
EventChannelFullModeTimeout = TimeSpan.FromMilliseconds(50),
|
||||||
HeartbeatGrace = TimeSpan.FromSeconds(30),
|
HeartbeatGrace = TimeSpan.FromSeconds(30),
|
||||||
HeartbeatCheckInterval = TimeSpan.FromSeconds(30),
|
HeartbeatCheckInterval = TimeSpan.FromSeconds(30),
|
||||||
});
|
});
|
||||||
@@ -163,6 +164,7 @@ public sealed class WorkerClientTests
|
|||||||
new WorkerClientOptions
|
new WorkerClientOptions
|
||||||
{
|
{
|
||||||
EventChannelCapacity = 1,
|
EventChannelCapacity = 1,
|
||||||
|
EventChannelFullModeTimeout = TimeSpan.FromMilliseconds(50),
|
||||||
HeartbeatGrace = TimeSpan.FromSeconds(30),
|
HeartbeatGrace = TimeSpan.FromSeconds(30),
|
||||||
HeartbeatCheckInterval = TimeSpan.FromSeconds(30),
|
HeartbeatCheckInterval = TimeSpan.FromSeconds(30),
|
||||||
},
|
},
|
||||||
@@ -374,6 +376,150 @@ public sealed class WorkerClientTests
|
|||||||
Assert.Equal(WorkerClientState.Faulted, client.State);
|
Assert.Equal(WorkerClientState.Faulted, client.State);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-031 regression: while a command is in flight on the
|
||||||
|
/// gateway↔worker pipe and the oldest pending command is younger
|
||||||
|
/// than <see cref="WorkerClientOptions.HeartbeatStuckCeiling"/>, the
|
||||||
|
/// heartbeat watchdog must NOT fault on heartbeat-expired alone — the
|
||||||
|
/// gap is more likely caused by pipe-write contention than by a hung
|
||||||
|
/// worker. Mirrors Worker-023 on the worker side.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task HeartbeatMonitor_WhenCommandInFlightWithinCeiling_DoesNotFaultOnExpiredHeartbeat()
|
||||||
|
{
|
||||||
|
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-05-20T13:00:00Z", System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
await using PipePair pipePair = await PipePair.CreateAsync();
|
||||||
|
await using WorkerClient client = CreateClient(
|
||||||
|
pipePair,
|
||||||
|
new WorkerClientOptions
|
||||||
|
{
|
||||||
|
HeartbeatGrace = TimeSpan.FromMilliseconds(80),
|
||||||
|
HeartbeatCheckInterval = TimeSpan.FromMilliseconds(20),
|
||||||
|
EventChannelCapacity = 8,
|
||||||
|
HeartbeatStuckCeiling = TimeSpan.FromSeconds(30),
|
||||||
|
},
|
||||||
|
timeProvider: clock);
|
||||||
|
await CompleteHandshakeAsync(client, pipePair);
|
||||||
|
|
||||||
|
// Begin a command that the test never replies to — keeps the
|
||||||
|
// PendingCommand alive in `_pendingCommands` for the duration.
|
||||||
|
Task<WorkerCommandReply> pendingInvoke = client.InvokeAsync(
|
||||||
|
CreateCommand(MxCommandKind.Ping),
|
||||||
|
TestTimeout,
|
||||||
|
CancellationToken.None);
|
||||||
|
WorkerEnvelope commandEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout);
|
||||||
|
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, commandEnvelope.BodyCase);
|
||||||
|
|
||||||
|
// Advance well past HeartbeatGrace but well within HeartbeatStuckCeiling.
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
// Give the heartbeat monitor a few real check-intervals to observe the gap.
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(150));
|
||||||
|
|
||||||
|
Assert.Equal(WorkerClientState.Ready, client.State);
|
||||||
|
Assert.False(pendingInvoke.IsCompleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-031 regression: once the oldest pending command exceeds
|
||||||
|
/// <see cref="WorkerClientOptions.HeartbeatStuckCeiling"/>, the
|
||||||
|
/// heartbeat watchdog fires anyway — a truly stuck COM call shouldn't
|
||||||
|
/// keep the watchdog suppressed indefinitely.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task HeartbeatMonitor_WhenPendingCommandExceedsStuckCeiling_FaultsClient()
|
||||||
|
{
|
||||||
|
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-05-20T13:00:00Z", System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
await using PipePair pipePair = await PipePair.CreateAsync();
|
||||||
|
await using WorkerClient client = CreateClient(
|
||||||
|
pipePair,
|
||||||
|
new WorkerClientOptions
|
||||||
|
{
|
||||||
|
HeartbeatGrace = TimeSpan.FromMilliseconds(80),
|
||||||
|
HeartbeatCheckInterval = TimeSpan.FromMilliseconds(20),
|
||||||
|
EventChannelCapacity = 8,
|
||||||
|
HeartbeatStuckCeiling = TimeSpan.FromMilliseconds(200),
|
||||||
|
},
|
||||||
|
timeProvider: clock);
|
||||||
|
await CompleteHandshakeAsync(client, pipePair);
|
||||||
|
|
||||||
|
Task<WorkerCommandReply> pendingInvoke = client.InvokeAsync(
|
||||||
|
CreateCommand(MxCommandKind.Ping),
|
||||||
|
TestTimeout,
|
||||||
|
CancellationToken.None);
|
||||||
|
await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout);
|
||||||
|
|
||||||
|
// Advance the clock past HeartbeatStuckCeiling. The worker pipe's
|
||||||
|
// PendingCommand.StartTimestamp uses TimeProvider.GetTimestamp(), so the
|
||||||
|
// ManualTimeProvider's GetElapsedTime sees the advanced gap.
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
await WaitUntilAsync(
|
||||||
|
() => client.State == WorkerClientState.Faulted,
|
||||||
|
TestTimeout);
|
||||||
|
|
||||||
|
Assert.Equal(WorkerClientState.Faulted, client.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-032 regression: a transient burst that exceeds
|
||||||
|
/// <see cref="WorkerClientOptions.EventChannelCapacity"/> must be
|
||||||
|
/// absorbed for up to <see cref="WorkerClientOptions.EventChannelFullModeTimeout"/>
|
||||||
|
/// (the channel is configured for <c>BoundedChannelFullMode.Wait</c>);
|
||||||
|
/// only when the wait elapses without progress is the worker faulted,
|
||||||
|
/// and the diagnostic must name the channel capacity, depth, and
|
||||||
|
/// actionable remediation.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task EnqueueWorkerEvent_WhenChannelFullPastTimeout_FaultsWithRichDiagnostic()
|
||||||
|
{
|
||||||
|
await using PipePair pipePair = await PipePair.CreateAsync();
|
||||||
|
await using WorkerClient client = CreateClient(
|
||||||
|
pipePair,
|
||||||
|
new WorkerClientOptions
|
||||||
|
{
|
||||||
|
EventChannelCapacity = 4,
|
||||||
|
EventChannelFullModeTimeout = TimeSpan.FromMilliseconds(100),
|
||||||
|
HeartbeatGrace = TimeSpan.FromSeconds(30),
|
||||||
|
HeartbeatCheckInterval = TimeSpan.FromSeconds(1),
|
||||||
|
});
|
||||||
|
await CompleteHandshakeAsync(client, pipePair);
|
||||||
|
|
||||||
|
// Fill the 4-slot channel and write exactly one more to force the
|
||||||
|
// overflow path. The gateway never opens a StreamEvents consumer, so
|
||||||
|
// the events stay buffered. Exactly five events are written: the
|
||||||
|
// worker client faults while reading the fifth, after which its read
|
||||||
|
// loop stops — a sixth event would never be drained and its pipe
|
||||||
|
// write would block forever on a full OS pipe buffer.
|
||||||
|
for (ulong sequence = 1; sequence <= 5; sequence++)
|
||||||
|
{
|
||||||
|
await pipePair.WorkerWriter.WriteAsync(
|
||||||
|
CreateEventEnvelope(sequence: sequence, MxEventFamily.OnDataChange));
|
||||||
|
}
|
||||||
|
|
||||||
|
await WaitUntilAsync(
|
||||||
|
() => client.State == WorkerClientState.Faulted,
|
||||||
|
TestTimeout);
|
||||||
|
|
||||||
|
Assert.Equal(WorkerClientState.Faulted, client.State);
|
||||||
|
|
||||||
|
// Reading the events channel after fault throws the propagated
|
||||||
|
// WorkerClientException carrying the rich diagnostic message. The
|
||||||
|
// drain is bounded by TestTimeout so a regression that leaves the
|
||||||
|
// channel uncompleted fails the test instead of hanging it.
|
||||||
|
using CancellationTokenSource drainTimeout = new(TestTimeout);
|
||||||
|
WorkerClientException fault = await Assert.ThrowsAsync<WorkerClientException>(async () =>
|
||||||
|
{
|
||||||
|
await foreach (WorkerEvent _ in client.ReadEventsAsync(drainTimeout.Token))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Assert.Contains("Worker event channel rejected", fault.Message);
|
||||||
|
Assert.Contains("of 4 capacity", fault.Message);
|
||||||
|
Assert.Contains("StreamEvents", fault.Message);
|
||||||
|
Assert.Contains("MxGateway:Events:QueueCapacity", fault.Message);
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkerClient CreateClient(
|
private static WorkerClient CreateClient(
|
||||||
PipePair pipePair,
|
PipePair pipePair,
|
||||||
WorkerClientOptions? options = null,
|
WorkerClientOptions? options = null,
|
||||||
|
|||||||
+6
-5
@@ -266,7 +266,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
|
|
||||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
() => interceptor.UnaryServerHandler(
|
() => interceptor.UnaryServerHandler(
|
||||||
new AcknowledgeAlarmRequest { SessionId = "session-1", AlarmFullReference = "ref" },
|
new AcknowledgeAlarmRequest { AlarmFullReference = "ref" },
|
||||||
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
||||||
(_, _) => Task.FromResult(new AcknowledgeAlarmReply())));
|
(_, _) => Task.FromResult(new AcknowledgeAlarmReply())));
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
bool handlerRan = false;
|
bool handlerRan = false;
|
||||||
|
|
||||||
AcknowledgeAlarmReply reply = await interceptor.UnaryServerHandler(
|
AcknowledgeAlarmReply reply = await interceptor.UnaryServerHandler(
|
||||||
new AcknowledgeAlarmRequest { SessionId = "session-1", AlarmFullReference = "ref" },
|
new AcknowledgeAlarmRequest { AlarmFullReference = "ref" },
|
||||||
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
||||||
(_, _) =>
|
(_, _) =>
|
||||||
{
|
{
|
||||||
@@ -310,7 +310,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
|
|
||||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
() => interceptor.ServerStreamingServerHandler(
|
() => interceptor.ServerStreamingServerHandler(
|
||||||
new QueryActiveAlarmsRequest { SessionId = "session-1" },
|
new StreamAlarmsRequest(),
|
||||||
new RecordingServerStreamWriter<ActiveAlarmSnapshot>(),
|
new RecordingServerStreamWriter<ActiveAlarmSnapshot>(),
|
||||||
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
||||||
(_, _, _) => Task.CompletedTask));
|
(_, _, _) => Task.CompletedTask));
|
||||||
@@ -329,7 +329,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
RecordingServerStreamWriter<ActiveAlarmSnapshot> streamWriter = new();
|
RecordingServerStreamWriter<ActiveAlarmSnapshot> streamWriter = new();
|
||||||
|
|
||||||
await interceptor.ServerStreamingServerHandler(
|
await interceptor.ServerStreamingServerHandler(
|
||||||
new QueryActiveAlarmsRequest { SessionId = "session-1" },
|
new StreamAlarmsRequest(),
|
||||||
streamWriter,
|
streamWriter,
|
||||||
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
||||||
async (_, writer, _) =>
|
async (_, writer, _) =>
|
||||||
@@ -352,7 +352,8 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
new MxAccessGrpcMapper(),
|
new MxAccessGrpcMapper(),
|
||||||
new NoOpEventStreamService(),
|
new NoOpEventStreamService(),
|
||||||
new GatewayMetrics(),
|
new GatewayMetrics(),
|
||||||
NullLogger<MxAccessGatewayService>.Instance);
|
NullLogger<MxAccessGatewayService>.Instance,
|
||||||
|
new FakeGatewayAlarmService());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GatewayGrpcAuthorizationInterceptor CreateInterceptor(
|
private static GatewayGrpcAuthorizationInterceptor CreateInterceptor(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public sealed class GatewayGrpcScopeResolverTests
|
|||||||
[InlineData(typeof(CloseSessionRequest), GatewayScopes.SessionClose)]
|
[InlineData(typeof(CloseSessionRequest), GatewayScopes.SessionClose)]
|
||||||
[InlineData(typeof(StreamEventsRequest), GatewayScopes.EventsRead)]
|
[InlineData(typeof(StreamEventsRequest), GatewayScopes.EventsRead)]
|
||||||
[InlineData(typeof(AcknowledgeAlarmRequest), GatewayScopes.InvokeWrite)]
|
[InlineData(typeof(AcknowledgeAlarmRequest), GatewayScopes.InvokeWrite)]
|
||||||
[InlineData(typeof(QueryActiveAlarmsRequest), GatewayScopes.EventsRead)]
|
[InlineData(typeof(StreamAlarmsRequest), GatewayScopes.EventsRead)]
|
||||||
[InlineData(typeof(TestConnectionRequest), GatewayScopes.MetadataRead)]
|
[InlineData(typeof(TestConnectionRequest), GatewayScopes.MetadataRead)]
|
||||||
[InlineData(typeof(GetLastDeployTimeRequest), GatewayScopes.MetadataRead)]
|
[InlineData(typeof(GetLastDeployTimeRequest), GatewayScopes.MetadataRead)]
|
||||||
[InlineData(typeof(DiscoverHierarchyRequest), GatewayScopes.MetadataRead)]
|
[InlineData(typeof(DiscoverHierarchyRequest), GatewayScopes.MetadataRead)]
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Alarms;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IGatewayAlarmService"/> test double — serves a scripted
|
||||||
|
/// active-alarm set and acknowledges every request with an OK status,
|
||||||
|
/// so gRPC service tests can exercise the alarm handlers without the
|
||||||
|
/// real gateway alarm monitor or a worker.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FakeGatewayAlarmService : IGatewayAlarmService
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public GatewayAlarmMonitorState State { get; set; } = GatewayAlarmMonitorState.Monitoring;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? LastError { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int? WorkerProcessId { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms { get; set; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
|
||||||
|
string? alarmFilterPrefix,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (ActiveAlarmSnapshot alarm in CurrentAlarms)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
yield return new AlarmFeedMessage { ActiveAlarm = alarm };
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return new AlarmFeedMessage { SnapshotComplete = true };
|
||||||
|
await Task.CompletedTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<AcknowledgeAlarmReply> AcknowledgeAsync(
|
||||||
|
AcknowledgeAlarmRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new AcknowledgeAlarmReply
|
||||||
|
{
|
||||||
|
CorrelationId = request.ClientCorrelationId,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
DiagnosticMessage = string.Empty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user