84fa2e1e43
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
96 lines
4.3 KiB
C#
96 lines
4.3 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Telemetry;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Telemetry;
|
|
|
|
/// <summary>
|
|
/// Production seams for <see cref="TelemetryDialSupervisor"/>: the DB-backed node source and the
|
|
/// gRPC dial loop. Kept out of the actor so the actor stays DB-free and gRPC-free (and therefore
|
|
/// unit-testable with fakes).
|
|
/// </summary>
|
|
public static class TelemetryNodeSource
|
|
{
|
|
/// <summary>
|
|
/// Builds the node source the supervisor polls: enabled, non-maintenance <c>ClusterNode</c>
|
|
/// rows carrying a non-null <c>GrpcPort</c>, projected to <c>http://{Host}:{GrpcPort}</c>
|
|
/// h2c endpoints. A row with a null <c>GrpcPort</c> exposes no telemetry surface yet and is
|
|
/// skipped with a Warning — mirrors <c>CentralCommunicationActor.LoadContactsFromDb</c>'s
|
|
/// enabled + non-maintenance read.
|
|
/// </summary>
|
|
/// <param name="dbFactory">Factory for the config database holding <c>ClusterNode</c> rows.</param>
|
|
/// <param name="logger">Logger for skipped-row diagnostics.</param>
|
|
/// <returns>An async delegate resolving the current dialable node set.</returns>
|
|
public static Func<Task<IReadOnlyList<TelemetryDialTarget>>> Create(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
|
ILogger logger)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(dbFactory);
|
|
ArgumentNullException.ThrowIfNull(logger);
|
|
|
|
// A persistently null-GrpcPort node is skipped on every refresh (every ContactRefreshSeconds);
|
|
// warn once per node id so it does not spam the log forever.
|
|
var warnedNullPort = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
return async () =>
|
|
{
|
|
await using var db = await dbFactory.CreateDbContextAsync().ConfigureAwait(false);
|
|
var rows = await db.ClusterNodes
|
|
.AsNoTracking()
|
|
.Where(n => n.Enabled && !n.MaintenanceMode)
|
|
.Select(n => new { n.NodeId, n.Host, n.GrpcPort })
|
|
.ToListAsync()
|
|
.ConfigureAwait(false);
|
|
|
|
var targets = new List<TelemetryDialTarget>(rows.Count);
|
|
foreach (var row in rows)
|
|
{
|
|
if (row.GrpcPort is null)
|
|
{
|
|
if (warnedNullPort.Add(row.NodeId))
|
|
{
|
|
logger.LogWarning(
|
|
"ClusterNode {NodeId} has no GrpcPort; it exposes no telemetry stream and is "
|
|
+ "skipped in this dial refresh (further skips of this node are silent)",
|
|
row.NodeId);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
targets.Add(new TelemetryDialTarget(row.NodeId, $"http://{row.Host}:{row.GrpcPort}"));
|
|
}
|
|
|
|
return (IReadOnlyList<TelemetryDialTarget>)targets;
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the production dial loop: one <see cref="TelemetryStreamClient"/> per stream, mapping
|
|
/// every wire envelope via <see cref="TelemetryProtoMapCentral.MapEvent"/> before handing it to
|
|
/// the supervisor. The client isolates a mapper throw (logs + continues) and routes only
|
|
/// transport failures to <c>onError</c>, so the supervisor sees a poison event as "dropped",
|
|
/// never as "node down".
|
|
/// </summary>
|
|
/// <param name="apiKey">Shared node bearer key sent on every dial.</param>
|
|
/// <param name="clientLogger">Logger passed to each per-stream client (dropped-event diagnostics).</param>
|
|
/// <returns>The dial-loop delegate the supervisor invokes per generation.</returns>
|
|
public static TelemetryDialLoop CreateDialLoop(
|
|
string apiKey,
|
|
ILogger<TelemetryStreamClient>? clientLogger)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(apiKey);
|
|
|
|
return async (target, correlationId, onMapped, onError, ct) =>
|
|
{
|
|
using var client = new TelemetryStreamClient(target.Endpoint, apiKey, clientLogger);
|
|
await client.RunAsync(
|
|
correlationId,
|
|
onEvent: evt => onMapped(TelemetryProtoMapCentral.MapEvent(evt)),
|
|
onError: onError,
|
|
ct).ConfigureAwait(false);
|
|
};
|
|
}
|
|
}
|