320 lines
12 KiB
C#
320 lines
12 KiB
C#
using Google.Protobuf.WellKnownTypes;
|
|
using Microsoft.Data.SqlClient;
|
|
using Microsoft.Extensions.Logging;
|
|
using MxGateway.Contracts.Proto.Galaxy;
|
|
using MxGateway.Server.Dashboard;
|
|
using MxGateway.Server.Grpc;
|
|
|
|
namespace MxGateway.Server.Galaxy;
|
|
|
|
/// <summary>
|
|
/// Server-side cache of Galaxy Repository browse data. All gRPC clients share the same
|
|
/// entry — the materialized <see cref="DiscoverHierarchyReply"/> is produced once per
|
|
/// 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 +
|
|
/// attributes rowsets are pulled only when that timestamp has advanced.
|
|
/// </summary>
|
|
public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
|
{
|
|
private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(5);
|
|
|
|
private readonly GalaxyRepository _repository;
|
|
private readonly IGalaxyDeployNotifier _notifier;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ILogger<GalaxyHierarchyCache>? _logger;
|
|
private readonly TaskCompletionSource _firstLoad = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
|
private GalaxyHierarchyCacheEntry _current = GalaxyHierarchyCacheEntry.Empty;
|
|
|
|
public GalaxyHierarchyCache(
|
|
GalaxyRepository repository,
|
|
IGalaxyDeployNotifier notifier,
|
|
TimeProvider? timeProvider = null,
|
|
ILogger<GalaxyHierarchyCache>? logger = null)
|
|
{
|
|
_repository = repository;
|
|
_notifier = notifier;
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
_logger = logger;
|
|
}
|
|
|
|
public GalaxyHierarchyCacheEntry Current
|
|
{
|
|
get
|
|
{
|
|
GalaxyHierarchyCacheEntry snapshot = Volatile.Read(ref _current);
|
|
GalaxyCacheStatus projected = ProjectStatus(snapshot);
|
|
return projected == snapshot.Status
|
|
? snapshot
|
|
: snapshot with
|
|
{
|
|
Status = projected,
|
|
DashboardSummary = snapshot.DashboardSummary with
|
|
{
|
|
Status = MapDashboardStatus(projected),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
public async Task RefreshAsync(CancellationToken cancellationToken)
|
|
{
|
|
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
await RefreshCoreAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
_refreshGate.Release();
|
|
}
|
|
}
|
|
|
|
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken)
|
|
{
|
|
return _firstLoad.Task.WaitAsync(cancellationToken);
|
|
}
|
|
|
|
private async Task RefreshCoreAsync(CancellationToken cancellationToken)
|
|
{
|
|
GalaxyHierarchyCacheEntry previous = Volatile.Read(ref _current);
|
|
DateTimeOffset queriedAt = _timeProvider.GetUtcNow();
|
|
|
|
try
|
|
{
|
|
DateTime? deployRaw = await _repository.GetLastDeployTimeAsync(cancellationToken).ConfigureAwait(false);
|
|
DateTimeOffset? deployTime = deployRaw.HasValue
|
|
? new DateTimeOffset(DateTime.SpecifyKind(deployRaw.Value, DateTimeKind.Utc))
|
|
: null;
|
|
|
|
bool hasPriorData = previous.HasData;
|
|
bool deployChanged = !hasPriorData || deployTime != previous.LastDeployTime;
|
|
|
|
if (!deployChanged)
|
|
{
|
|
// No deploy change — skip heavy queries; just bump LastSuccessAt.
|
|
GalaxyHierarchyCacheEntry refreshed = previous with
|
|
{
|
|
Status = GalaxyCacheStatus.Healthy,
|
|
LastQueriedAt = queriedAt,
|
|
LastSuccessAt = queriedAt,
|
|
LastError = null,
|
|
DashboardSummary = previous.DashboardSummary with
|
|
{
|
|
Status = DashboardGalaxyStatus.Healthy,
|
|
LastQueriedAt = queriedAt,
|
|
LastSuccessAt = queriedAt,
|
|
LastDeployTime = deployTime,
|
|
LastError = null,
|
|
},
|
|
};
|
|
Volatile.Write(ref _current, refreshed);
|
|
_firstLoad.TrySetResult();
|
|
return;
|
|
}
|
|
|
|
Task<List<GalaxyHierarchyRow>> hierarchyTask = _repository.GetHierarchyAsync(cancellationToken);
|
|
Task<List<GalaxyAttributeRow>> attributesTask = _repository.GetAttributesAsync(cancellationToken);
|
|
await Task.WhenAll(hierarchyTask, attributesTask).ConfigureAwait(false);
|
|
|
|
List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
|
|
List<GalaxyAttributeRow> attributes = attributesTask.Result;
|
|
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: GalaxyCacheStatus.Healthy,
|
|
lastQueriedAt: queriedAt,
|
|
lastSuccessAt: queriedAt,
|
|
lastDeployTime: deployTime,
|
|
lastError: null,
|
|
hierarchy: hierarchy,
|
|
objectCount: hierarchy.Count,
|
|
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);
|
|
_firstLoad.TrySetResult();
|
|
|
|
_notifier.Publish(new GalaxyDeployEventInfo(
|
|
Sequence: nextSequence,
|
|
ObservedAt: queriedAt,
|
|
TimeOfLastDeploy: deployTime,
|
|
ObjectCount: hierarchy.Count,
|
|
AttributeCount: attributes.Count));
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception exception) when (exception is SqlException or InvalidOperationException)
|
|
{
|
|
_logger?.LogWarning(exception, "Galaxy hierarchy cache refresh failed.");
|
|
GalaxyHierarchyCacheEntry failed = previous with
|
|
{
|
|
Status = previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable,
|
|
LastQueriedAt = queriedAt,
|
|
LastError = exception.Message,
|
|
DashboardSummary = previous.DashboardSummary with
|
|
{
|
|
Status = MapDashboardStatus(previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable),
|
|
LastQueriedAt = queriedAt,
|
|
LastError = exception.Message,
|
|
},
|
|
};
|
|
Volatile.Write(ref _current, failed);
|
|
_firstLoad.TrySetResult();
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<GalaxyObject> BuildObjects(
|
|
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
|
IReadOnlyList<GalaxyAttributeRow> attributes)
|
|
{
|
|
Dictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId = attributes
|
|
.GroupBy(a => a.GobjectId)
|
|
.ToDictionary(g => g.Key, g => g.ToList());
|
|
|
|
List<GalaxyObject> objects = new(hierarchy.Count);
|
|
foreach (GalaxyHierarchyRow row in hierarchy)
|
|
{
|
|
objects.Add(GalaxyProtoMapper.MapObject(row, attributesByGobjectId));
|
|
}
|
|
return objects;
|
|
}
|
|
|
|
private static DashboardGalaxySummary BuildDashboardSummary(
|
|
GalaxyCacheStatus status,
|
|
DateTimeOffset? lastQueriedAt,
|
|
DateTimeOffset? lastSuccessAt,
|
|
DateTimeOffset? lastDeployTime,
|
|
string? lastError,
|
|
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
|
int objectCount,
|
|
int areaCount,
|
|
int attributeCount,
|
|
int historizedAttributeCount,
|
|
int alarmAttributeCount)
|
|
{
|
|
IReadOnlyList<DashboardGalaxyTemplateUsage> topTemplates;
|
|
IReadOnlyList<DashboardGalaxyCategoryCount> objectCategories;
|
|
|
|
if (hierarchy.Count == 0)
|
|
{
|
|
topTemplates = Array.Empty<DashboardGalaxyTemplateUsage>();
|
|
objectCategories = Array.Empty<DashboardGalaxyCategoryCount>();
|
|
}
|
|
else
|
|
{
|
|
Dictionary<int, int> objectsByCategory = new();
|
|
Dictionary<string, int> templateUsage = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (GalaxyHierarchyRow row in hierarchy)
|
|
{
|
|
objectsByCategory.TryGetValue(row.CategoryId, out int categoryCount);
|
|
objectsByCategory[row.CategoryId] = categoryCount + 1;
|
|
|
|
if (row.TemplateChain.Count > 0)
|
|
{
|
|
string immediate = row.TemplateChain[0];
|
|
if (!string.IsNullOrWhiteSpace(immediate))
|
|
{
|
|
templateUsage.TryGetValue(immediate, out int templateCount);
|
|
templateUsage[immediate] = templateCount + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
topTemplates = templateUsage
|
|
.OrderByDescending(entry => entry.Value)
|
|
.ThenBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
|
|
.Take(10)
|
|
.Select(entry => new DashboardGalaxyTemplateUsage(entry.Key, entry.Value))
|
|
.ToArray();
|
|
|
|
objectCategories = objectsByCategory
|
|
.OrderByDescending(entry => entry.Value)
|
|
.ThenBy(entry => entry.Key)
|
|
.Select(entry => new DashboardGalaxyCategoryCount(
|
|
entry.Key,
|
|
ResolveCategoryName(entry.Key),
|
|
entry.Value))
|
|
.ToArray();
|
|
}
|
|
|
|
return new DashboardGalaxySummary(
|
|
Status: MapDashboardStatus(status),
|
|
LastQueriedAt: lastQueriedAt,
|
|
LastSuccessAt: lastSuccessAt,
|
|
LastDeployTime: lastDeployTime,
|
|
LastError: lastError,
|
|
ObjectCount: objectCount,
|
|
AreaCount: areaCount,
|
|
AttributeCount: attributeCount,
|
|
HistorizedAttributeCount: historizedAttributeCount,
|
|
AlarmAttributeCount: alarmAttributeCount,
|
|
TopTemplates: topTemplates,
|
|
ObjectCategories: objectCategories);
|
|
}
|
|
|
|
private static DashboardGalaxyStatus MapDashboardStatus(GalaxyCacheStatus status) => status switch
|
|
{
|
|
GalaxyCacheStatus.Healthy => DashboardGalaxyStatus.Healthy,
|
|
GalaxyCacheStatus.Stale => DashboardGalaxyStatus.Stale,
|
|
GalaxyCacheStatus.Unavailable => DashboardGalaxyStatus.Unavailable,
|
|
_ => DashboardGalaxyStatus.Unknown,
|
|
};
|
|
|
|
private static string ResolveCategoryName(int categoryId) => categoryId switch
|
|
{
|
|
1 => "WinPlatform",
|
|
3 => "AppEngine",
|
|
4 => "InTouchViewApp",
|
|
10 => "UserDefined",
|
|
11 => "FieldReference",
|
|
13 => "Area",
|
|
17 => "DIObject",
|
|
24 => "DDESuiteLinkClient",
|
|
26 => "OPCClient",
|
|
_ => $"Category {categoryId}",
|
|
};
|
|
|
|
private GalaxyCacheStatus ProjectStatus(GalaxyHierarchyCacheEntry snapshot)
|
|
{
|
|
if (snapshot.Status is GalaxyCacheStatus.Unknown or GalaxyCacheStatus.Unavailable)
|
|
{
|
|
return snapshot.Status;
|
|
}
|
|
|
|
if (snapshot.LastSuccessAt is { } success
|
|
&& _timeProvider.GetUtcNow() - success > StaleThreshold)
|
|
{
|
|
return GalaxyCacheStatus.Stale;
|
|
}
|
|
|
|
return snapshot.Status;
|
|
}
|
|
}
|