dc9c0c950c
Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.
External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths
Also fixes two tests that were not rename-related but became visible
while validating the rename:
- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
gateway service correctly maps to RpcException(Cancelled) per gRPC
convention was being misclassified as a stream fault. Added a sibling
catch on RpcException with StatusCode.Cancelled.
- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
and made it accept either a .git marker OR a .sln/.slnx next to src/
so the worker-exe walker works in non-git working copies.
clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.
Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
Tests: 472/472 pass
Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
IntegrationTests: 18/18 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
490 lines
20 KiB
C#
490 lines
20 KiB
C#
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;
|
|
|
|
/// <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.
|
|
/// 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>
|
|
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<GalaxyHierarchyCache>? _logger;
|
|
private readonly TaskCompletionSource _firstLoad = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
|
private GalaxyHierarchyCacheEntry _current = GalaxyHierarchyCacheEntry.Empty;
|
|
private bool _restoreAttempted;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="GalaxyHierarchyCache"/> class.</summary>
|
|
/// <param name="repository">Galaxy Repository client for SQL queries.</param>
|
|
/// <param name="notifier">Galaxy deploy event notifier.</param>
|
|
/// <param name="timeProvider">Provider for current time; defaults to system time.</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(
|
|
IGalaxyRepository repository,
|
|
IGalaxyDeployNotifier notifier,
|
|
TimeProvider? timeProvider = null,
|
|
ILogger<GalaxyHierarchyCache>? logger = null,
|
|
IGalaxyHierarchySnapshotStore? snapshotStore = null)
|
|
{
|
|
_repository = repository;
|
|
_notifier = notifier;
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
_logger = logger;
|
|
_snapshotStore = snapshotStore;
|
|
}
|
|
|
|
/// <summary>Gets the current Galaxy hierarchy cache entry with projected status.</summary>
|
|
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),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>Refreshes the Galaxy hierarchy cache if the deploy time has advanced.</summary>
|
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
/// <returns>Asynchronous task representing the refresh operation.</returns>
|
|
public async Task RefreshAsync(CancellationToken cancellationToken)
|
|
{
|
|
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
await RefreshCoreAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
_refreshGate.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>Waits for the Galaxy hierarchy cache to complete its first load.</summary>
|
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
/// <returns>Asynchronous task representing the wait operation.</returns>
|
|
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<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;
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <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(
|
|
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;
|
|
}
|
|
}
|