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; /// /// Server-side cache of Galaxy Repository browse data. All gRPC clients share the same /// entry — the materialized is produced once per /// refresh and reused across requests. Refreshes are deploy-time gated: every tick /// queries galaxy.time_of_last_deploy (cheap), and the heavy hierarchy + /// attributes rowsets are pulled only when that timestamp has advanced. /// 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? _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? 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> hierarchyTask = _repository.GetHierarchyAsync(cancellationToken); Task> attributesTask = _repository.GetAttributesAsync(cancellationToken); await Task.WhenAll(hierarchyTask, attributesTask).ConfigureAwait(false); List hierarchy = hierarchyTask.Result; List attributes = attributesTask.Result; IReadOnlyList 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 BuildObjects( IReadOnlyList hierarchy, IReadOnlyList attributes) { Dictionary> attributesByGobjectId = attributes .GroupBy(a => a.GobjectId) .ToDictionary(g => g.Key, g => g.ToList()); List 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 hierarchy, int objectCount, int areaCount, int attributeCount, int historizedAttributeCount, int alarmAttributeCount) { IReadOnlyList topTemplates; IReadOnlyList objectCategories; if (hierarchy.Count == 0) { topTemplates = Array.Empty(); objectCategories = Array.Empty(); } else { Dictionary objectsByCategory = new(); Dictionary 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; } }