using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Grpc; namespace ZB.MOM.WW.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. /// Each successful heavy refresh is persisted to disk through /// ; the first refresh restores that /// snapshot (as ) so clients can browse /// last-known data when the Galaxy database is unreachable on a cold start. /// public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache { private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(5); private readonly IGalaxyRepository _repository; private readonly IGalaxyDeployNotifier _notifier; private readonly IGalaxyHierarchySnapshotStore? _snapshotStore; 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; private bool _restoreAttempted; /// Initializes a new instance of the class. /// Galaxy Repository client for SQL queries. /// Galaxy deploy event notifier. /// Provider for current time; defaults to system time. /// Optional logger for diagnostic output. /// /// Optional on-disk snapshot store. When supplied, the cache persists each /// successful refresh and restores the last snapshot on first load. /// public GalaxyHierarchyCache( IGalaxyRepository repository, IGalaxyDeployNotifier notifier, TimeProvider? timeProvider = null, ILogger? logger = null, IGalaxyHierarchySnapshotStore? snapshotStore = null) { _repository = repository; _notifier = notifier; _timeProvider = timeProvider ?? TimeProvider.System; _logger = logger; _snapshotStore = snapshotStore; } /// Gets the current Galaxy hierarchy cache entry with projected status. 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), }, }; } } /// Refreshes the Galaxy hierarchy cache if the deploy time has advanced. /// Token to cancel the asynchronous operation. /// Asynchronous task representing the refresh operation. public async Task RefreshAsync(CancellationToken cancellationToken) { await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { await RefreshCoreAsync(cancellationToken).ConfigureAwait(false); } finally { _refreshGate.Release(); } } /// Waits for the Galaxy hierarchy cache to complete its first load. /// Token to cancel the asynchronous operation. /// Asynchronous task representing the wait operation. public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) { return _firstLoad.Task.WaitAsync(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); 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; long nextSequence = previous.Sequence + 1; GalaxyHierarchyCacheEntry next = BuildEntry( status: GalaxyCacheStatus.Healthy, sequence: nextSequence, lastQueriedAt: queriedAt, lastSuccessAt: queriedAt, lastDeployTime: deployTime, lastError: null, hierarchy: hierarchy, attributes: attributes); Volatile.Write(ref _current, next); _firstLoad.TrySetResult(); _notifier.Publish(new GalaxyDeployEventInfo( Sequence: nextSequence, ObservedAt: queriedAt, TimeOfLastDeploy: deployTime, ObjectCount: hierarchy.Count, AttributeCount: attributes.Count)); await PersistSnapshotAsync(deployTime, queriedAt, hierarchy, attributes, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception exception) { // Catch every non-cancellation failure — not just SqlException / // InvalidOperationException. A TimeoutException or Win32Exception // from connection establishment, or another DbException subtype, // must still degrade gracefully to Stale/Unavailable and complete // _firstLoad rather than escape and fault the refresh BackgroundService. _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(); } } /// /// Materializes a complete 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. /// private static GalaxyHierarchyCacheEntry BuildEntry( GalaxyCacheStatus status, long sequence, DateTimeOffset? lastQueriedAt, DateTimeOffset? lastSuccessAt, DateTimeOffset? lastDeployTime, string? lastError, IReadOnlyList hierarchy, IReadOnlyList attributes) { 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: 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); } /// /// Seeds the cache from the on-disk snapshot when no live data has loaded yet. /// The restored entry is marked — 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. /// 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); } /// /// Persists a successful refresh to disk. Persistence failures are logged and /// swallowed — a cache that cannot write its backup is still fully usable. /// private async Task PersistSnapshotAsync( DateTimeOffset? deployTime, DateTimeOffset savedAt, IReadOnlyList hierarchy, IReadOnlyList 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 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; } }