fix(galaxyrepository): P3 review fixes 003-008 - cycle re-rooting, scaled stale threshold, notifier ordering, DI tests, Pkcs 9.0.21 pin, snapshot docs; bump 0.2.2 (scadaproj#47)
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -2,6 +2,17 @@
|
||||
|
||||
Shared Galaxy object-hierarchy SQL browse + `galaxy_repository.v1` gRPC service for the **ZB.MOM.WW SCADA family** (consumed by HistorianGateway and mxaccessgw). Build/test from this directory: `dotnet test`.
|
||||
|
||||
## 0.2.2 (2026-11-09, UNPUBLISHED)
|
||||
|
||||
Version bumped in `Directory.Build.props`, not packed or pushed. P3 review fixes (scadaproj#47):
|
||||
- 003: parent cycles are broken at the lowest gobject id so their members stay browsable.
|
||||
- 004: Stale threshold is `max(5 min, 3 x DashboardRefreshIntervalSeconds)` (new optional `IOptions` ctor
|
||||
parameter on `GalaxyHierarchyCache`); the projected-Stale entry is memoized per source entry.
|
||||
- 005: `WatchDeployEvents` subscribers never see a non-increasing `Sequence`.
|
||||
- 006: container-building tests for `AddZbGalaxyRepository`.
|
||||
- 007: `System.Security.Cryptography.Pkcs` pinned at 9.0.21 (clears NU1902/NU1903 for consumers).
|
||||
- 008: empty `SnapshotCachePath` disables persistence (documented + logged once).
|
||||
|
||||
## 0.2.1 (2026-11-09, UNPUBLISHED)
|
||||
|
||||
Version bumped in `Directory.Build.props`, not packed or pushed; consumers stay on `0.2.0`.
|
||||
@@ -34,7 +45,6 @@ the Gitea NuGet feed** `gitea.dohertylan.com/api/packages/dohertj2/nuget`). The
|
||||
[`docs/plans/2026-06-23-historian-gateway-design.md`](docs/plans/2026-06-23-historian-gateway-design.md) (§10, component 1).
|
||||
**Consumed by HistorianGateway** as a `PackageReference` from that Gitea feed, pinned at `0.2.0` (originally a
|
||||
cross-repo `ProjectReference` to this scadaproj tree; switched to the feed package 2026-06-24; bumped `0.1.0` → `0.2.0` 2026-06-25).
|
||||
**mxaccessgw adoption is a tracked follow-on** — once adopted, mxaccessgw's inline Galaxy browse code is replaced
|
||||
by the shared lib (the `galaxy_repository.v1` wire contract is already identical, so OtOpcUa and ScadaBridge
|
||||
clients are unaffected). Build/test from `ZB.MOM.WW.GalaxyRepository/`: `dotnet test`.
|
||||
Consumer matrix: HistorianGateway (initial); mxaccessgw (follow-on adoption).
|
||||
**mxaccessgw has adopted it** (its Server calls `AddZbGalaxyRepository` / `MapZbGalaxyRepository`; the
|
||||
`galaxy_repository.v1` wire contract is identical, so OtOpcUa and ScadaBridge clients are unaffected). Build/test from `ZB.MOM.WW.GalaxyRepository/`: `dotnet test`.
|
||||
Consumer matrix: HistorianGateway (initial); mxaccessgw (adopted, 0.2.0).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.2.1</Version>
|
||||
<Version>0.2.2</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
<ItemGroup>
|
||||
<!-- Library -->
|
||||
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||
<!-- Transitive security pin (scadaproj#22/#47): Microsoft.Data.SqlClient 6.0.2 pulls
|
||||
System.Security.Cryptography.Pkcs 9.0.4, which carries GHSA-846f-f764-76mx (high) and
|
||||
GHSA-2fmv-rv2c-59x7 (moderate) and fails restore under warnings-as-errors (NU1902/NU1903).
|
||||
Pin the patched 9.0.21 (first fixed in the 9.0 line). -->
|
||||
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="9.0.21" />
|
||||
<PackageVersion Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||
<!-- Google.Protobuf and Grpc.Tools must be >= the minimums Grpc.AspNetCore 2.76.0 requires -->
|
||||
<PackageVersion Include="Google.Protobuf" Version="3.31.1" />
|
||||
|
||||
@@ -60,12 +60,21 @@ public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier
|
||||
channel.Writer.TryWrite(bootstrap);
|
||||
}
|
||||
|
||||
long lastSequence = long.MinValue;
|
||||
try
|
||||
{
|
||||
while (await channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
while (channel.Reader.TryRead(out GalaxyDeployEventInfo? next))
|
||||
{
|
||||
// The bootstrap write can race a concurrent Publish and land behind a
|
||||
// newer event; never yield an event that is not newer than the last one.
|
||||
if (next.Sequence <= lastSequence)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
lastSequence = next.Sequence;
|
||||
yield return next;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,17 +16,22 @@ namespace ZB.MOM.WW.GalaxyRepository;
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache, IDisposable
|
||||
{
|
||||
private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(5);
|
||||
private static readonly TimeSpan MinimumStaleThreshold = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly IGalaxyRepository _repository;
|
||||
private readonly IGalaxyDeployNotifier _notifier;
|
||||
private readonly IGalaxyHierarchySnapshotStore? _snapshotStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly TimeSpan _staleThreshold;
|
||||
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;
|
||||
private ProjectedStale? _projectedStale;
|
||||
|
||||
/// <summary>Memoized status-only projection so repeated reads return the same instance (keeps projector memos warm).</summary>
|
||||
private sealed record ProjectedStale(GalaxyHierarchyCacheEntry Source, GalaxyHierarchyCacheEntry Projected);
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="GalaxyHierarchyCache"/> class.</summary>
|
||||
/// <param name="repository">Galaxy Repository client for SQL queries.</param>
|
||||
@@ -37,13 +42,21 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache, IDisposable
|
||||
/// Optional on-disk snapshot store. When supplied, the cache persists each
|
||||
/// successful refresh and restores the last snapshot on first load.
|
||||
/// </param>
|
||||
/// <param name="options">
|
||||
/// Optional options; the staleness threshold is <c>max(5 min, 3 x DashboardRefreshIntervalSeconds)</c>
|
||||
/// so a healthy cache on a slow refresh interval is not reported Stale.
|
||||
/// </param>
|
||||
public GalaxyHierarchyCache(
|
||||
IGalaxyRepository repository,
|
||||
IGalaxyDeployNotifier notifier,
|
||||
TimeProvider? timeProvider = null,
|
||||
ILogger<GalaxyHierarchyCache>? logger = null,
|
||||
IGalaxyHierarchySnapshotStore? snapshotStore = null)
|
||||
IGalaxyHierarchySnapshotStore? snapshotStore = null,
|
||||
Microsoft.Extensions.Options.IOptions<GalaxyRepositoryOptions>? options = null)
|
||||
{
|
||||
int refreshSeconds = options?.Value.DashboardRefreshIntervalSeconds ?? 0;
|
||||
TimeSpan derived = TimeSpan.FromSeconds(Math.Max(0, refreshSeconds) * 3.0);
|
||||
_staleThreshold = derived > MinimumStaleThreshold ? derived : MinimumStaleThreshold;
|
||||
_repository = repository;
|
||||
_notifier = notifier;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
@@ -58,9 +71,22 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache, IDisposable
|
||||
{
|
||||
GalaxyHierarchyCacheEntry snapshot = Volatile.Read(ref _current);
|
||||
GalaxyCacheStatus projected = ProjectStatus(snapshot);
|
||||
return projected == snapshot.Status
|
||||
? snapshot
|
||||
: snapshot with { Status = projected };
|
||||
if (projected == snapshot.Status)
|
||||
{
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// Reuse the projected record while the source entry is unchanged: the projectors
|
||||
// memoize per entry instance, so a fresh record per read defeats the memo.
|
||||
ProjectedStale? memo = Volatile.Read(ref _projectedStale);
|
||||
if (memo is not null && ReferenceEquals(memo.Source, snapshot) && memo.Projected.Status == projected)
|
||||
{
|
||||
return memo.Projected;
|
||||
}
|
||||
|
||||
GalaxyHierarchyCacheEntry created = snapshot with { Status = projected };
|
||||
Volatile.Write(ref _projectedStale, new ProjectedStale(snapshot, created));
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +381,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache, IDisposable
|
||||
}
|
||||
|
||||
if (snapshot.LastSuccessAt is { } success
|
||||
&& _timeProvider.GetUtcNow() - success > StaleThreshold)
|
||||
&& _timeProvider.GetUtcNow() - success > _staleThreshold)
|
||||
{
|
||||
return GalaxyCacheStatus.Stale;
|
||||
}
|
||||
|
||||
@@ -127,6 +127,8 @@ public sealed class GalaxyHierarchyIndex
|
||||
bucket.Add(view);
|
||||
}
|
||||
|
||||
ReRootUnreachable(views, childrenByParent);
|
||||
|
||||
foreach (List<GalaxyObjectView> bucket in childrenByParent.Values)
|
||||
{
|
||||
bucket.Sort(CompareByAreaThenDisplayName);
|
||||
@@ -147,6 +149,109 @@ public sealed class GalaxyHierarchyIndex
|
||||
viewsByContainedPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-roots objects that are not reachable from key 0 — members of a multi-node parent
|
||||
/// cycle (A→B→C→A, every parent present) and anything hanging off one — so they do not
|
||||
/// vanish from browse. Each cycle is broken at its lowest gobject id.
|
||||
/// </summary>
|
||||
private static void ReRootUnreachable(
|
||||
List<GalaxyObjectView> views,
|
||||
Dictionary<int, List<GalaxyObjectView>> childrenByParent)
|
||||
{
|
||||
HashSet<int> reached = [];
|
||||
MarkReachable(0, childrenByParent, reached);
|
||||
if (reached.Count >= views.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Ascending id: a cycle member with the lowest id is re-rooted before any other
|
||||
// member; a descendant hanging off a cycle is reached once its cycle is re-rooted.
|
||||
foreach (GalaxyObjectView view in views.OrderBy(v => v.Object.GobjectId))
|
||||
{
|
||||
int id = view.Object.GobjectId;
|
||||
if (reached.Contains(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only break at a node that is itself on a cycle (walking parents returns to it);
|
||||
// a plain descendant of a cycle waits for its ancestor cycle to be broken.
|
||||
if (!IsOnCycle(view, childrenByParent))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
childrenByParent[view.Object.ParentGobjectId].Remove(view);
|
||||
if (!childrenByParent.TryGetValue(0, out List<GalaxyObjectView>? roots))
|
||||
{
|
||||
roots = [];
|
||||
childrenByParent[0] = roots;
|
||||
}
|
||||
|
||||
roots.Add(view);
|
||||
MarkReachable(0, childrenByParent, reached);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsOnCycle(
|
||||
GalaxyObjectView start,
|
||||
Dictionary<int, List<GalaxyObjectView>> childrenByParent)
|
||||
{
|
||||
// Descend from start through children; on a cycle we come back to start.
|
||||
HashSet<int> seen = [];
|
||||
Stack<int> stack = new();
|
||||
stack.Push(start.Object.GobjectId);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
int key = stack.Pop();
|
||||
if (!childrenByParent.TryGetValue(key, out List<GalaxyObjectView>? kids))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (GalaxyObjectView kid in kids)
|
||||
{
|
||||
int kidId = kid.Object.GobjectId;
|
||||
if (kidId == start.Object.GobjectId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (seen.Add(kidId))
|
||||
{
|
||||
stack.Push(kidId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void MarkReachable(
|
||||
int rootKey,
|
||||
Dictionary<int, List<GalaxyObjectView>> childrenByParent,
|
||||
HashSet<int> reached)
|
||||
{
|
||||
Stack<int> stack = new();
|
||||
stack.Push(rootKey);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
if (!childrenByParent.TryGetValue(stack.Pop(), out List<GalaxyObjectView>? kids))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (GalaxyObjectView kid in kids)
|
||||
{
|
||||
if (reached.Add(kid.Object.GobjectId))
|
||||
{
|
||||
stack.Push(kid.Object.GobjectId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildContainedPath(
|
||||
GalaxyObject obj,
|
||||
IReadOnlyDictionary<int, GalaxyObject> objectsById)
|
||||
|
||||
+7
@@ -46,6 +46,13 @@ public sealed class GalaxyHierarchySnapshotStore : IGalaxyHierarchySnapshotStore
|
||||
: null;
|
||||
_writeTimeout = TimeSpan.FromSeconds(Math.Max(1, value.CommandTimeoutSeconds));
|
||||
_logger = logger;
|
||||
|
||||
if (value.PersistSnapshot && _path is null)
|
||||
{
|
||||
_logger?.LogInformation(
|
||||
"Galaxy hierarchy snapshot persistence is disabled: PersistSnapshot is true but SnapshotCachePath is empty. "
|
||||
+ "Last-known browse data will not survive a restart while the Galaxy database is unreachable.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
+3
-2
@@ -48,8 +48,9 @@ public sealed class GalaxyRepositoryOptions
|
||||
/// File path for the persisted Galaxy browse snapshot. Ignored when
|
||||
/// <see cref="PersistSnapshot"/> is <see langword="false"/>. There is no built-in
|
||||
/// default path — the consumer supplies a cross-platform-friendly path appropriate to
|
||||
/// its host. When left empty and <see cref="PersistSnapshot"/> is enabled, the
|
||||
/// snapshot store (a later task) decides where to write.
|
||||
/// its host. When left empty, snapshot persistence is <b>disabled</b> even if
|
||||
/// <see cref="PersistSnapshot"/> is <see langword="true"/> (the store logs this once at
|
||||
/// Information), so the cold-start last-known-data guarantee does not apply.
|
||||
/// </summary>
|
||||
public string SnapshotCachePath { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
+2
-1
@@ -12,6 +12,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<!-- Direct reference so the centrally pinned, patched version flows to consumers (GalaxyRepository-007). -->
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" />
|
||||
<PackageReference Include="Grpc.AspNetCore" />
|
||||
<PackageReference Include="Google.Protobuf" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
@@ -22,7 +24,6 @@
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Proto files are added in Task 2; the empty glob is intentional and builds cleanly. -->
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Protos\*.proto" GrpcServices="Server" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.GalaxyRepository.DependencyInjection;
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||
|
||||
/// <summary>Regression tests for GalaxyRepository-003..008 (scadaproj#47).</summary>
|
||||
public sealed class ReviewP3Tests
|
||||
{
|
||||
private static GalaxyObject Obj(int id, int parent, string name)
|
||||
{
|
||||
GalaxyObject o = new() { GobjectId = id, ParentGobjectId = parent, ContainedName = name, BrowseName = name, TagName = name };
|
||||
return o;
|
||||
}
|
||||
|
||||
// ---- 003 ----
|
||||
[Fact]
|
||||
public void ParentCycle_members_are_reachable_from_the_root_browse()
|
||||
{
|
||||
GalaxyObject[] objects =
|
||||
[
|
||||
Obj(1, 0, "Plant"),
|
||||
Obj(10, 12, "A"),
|
||||
Obj(11, 10, "B"),
|
||||
Obj(12, 11, "C"),
|
||||
Obj(13, 12, "Leaf"), // hangs off the cycle
|
||||
];
|
||||
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
|
||||
{
|
||||
Status = GalaxyCacheStatus.Healthy,
|
||||
Sequence = 1,
|
||||
LastSuccessAt = DateTimeOffset.UtcNow,
|
||||
Objects = objects,
|
||||
Index = GalaxyHierarchyIndex.Build(objects),
|
||||
ObjectCount = objects.Length,
|
||||
};
|
||||
|
||||
HashSet<string> seen = [];
|
||||
Walk(entry, new BrowseChildrenRequest(), seen, 0);
|
||||
|
||||
Assert.Equal(new[] { "A", "B", "C", "Leaf", "Plant" }, seen.Order(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
private static void Walk(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest req, HashSet<string> seen, int depth)
|
||||
{
|
||||
Assert.True(depth < 20, "browse did not terminate");
|
||||
GalaxyBrowseChildrenResult r = GalaxyBrowseProjector.ProjectChildren(entry, req, null, 0, 100);
|
||||
foreach (GalaxyObject child in r.Children)
|
||||
{
|
||||
if (seen.Add(child.TagName))
|
||||
{
|
||||
Walk(entry, new BrowseChildrenRequest { ParentTagName = child.TagName }, seen, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 004 ----
|
||||
[Fact]
|
||||
public async Task SlowRefreshInterval_does_not_report_Stale_before_three_intervals()
|
||||
{
|
||||
DateTimeOffset now = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
FakeGalaxyRepository repo = new(
|
||||
[new() { GobjectId = 1, TagName = "A", ContainedName = "A", BrowseName = "A" }], [], new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
StubTimeProvider clock = new(now);
|
||||
IOptions<GalaxyRepositoryOptions> opts = Options.Create(new GalaxyRepositoryOptions { DashboardRefreshIntervalSeconds = 600 });
|
||||
using GalaxyHierarchyCache cache = new(repo, new RecordingDeployNotifier(), clock, options: opts);
|
||||
await cache.RefreshAsync(CancellationToken.None);
|
||||
|
||||
clock.Advance(TimeSpan.FromMinutes(10)); // one 600 s interval
|
||||
Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);
|
||||
clock.Advance(TimeSpan.FromMinutes(21)); // > 3 intervals
|
||||
Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_projection_returns_the_same_instance_so_projector_memos_stay_warm()
|
||||
{
|
||||
StubTimeProvider clock = new(new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero));
|
||||
FakeGalaxyRepository repo = new(
|
||||
[new() { GobjectId = 1, TagName = "A", ContainedName = "A", BrowseName = "A" }], [], new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
using GalaxyHierarchyCache cache = new(repo, new RecordingDeployNotifier(), clock);
|
||||
await cache.RefreshAsync(CancellationToken.None);
|
||||
clock.Advance(TimeSpan.FromMinutes(6));
|
||||
|
||||
Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status);
|
||||
Assert.Same(cache.Current, cache.Current);
|
||||
}
|
||||
|
||||
// ---- 005 ----
|
||||
[Fact]
|
||||
public async Task Subscriber_never_yields_an_older_event_after_a_newer_one()
|
||||
{
|
||||
GalaxyDeployNotifier notifier = new();
|
||||
GalaxyDeployEventInfo older = new(1, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 1, 1);
|
||||
GalaxyDeployEventInfo newer = new(2, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 1, 1);
|
||||
notifier.Publish(older);
|
||||
|
||||
using CancellationTokenSource cts = new();
|
||||
await using IAsyncEnumerator<GalaxyDeployEventInfo> e = notifier.SubscribeAsync(cts.Token).GetAsyncEnumerator(cts.Token);
|
||||
Assert.True(await e.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)));
|
||||
Assert.Same(older, e.Current);
|
||||
|
||||
// Simulate the race outcome: channel holds [newer, older] -> publish newer then replay older.
|
||||
notifier.Publish(newer);
|
||||
notifier.Publish(older);
|
||||
Assert.True(await e.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)));
|
||||
Assert.Same(newer, e.Current);
|
||||
|
||||
notifier.Publish(new GalaxyDeployEventInfo(3, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 1, 1));
|
||||
Assert.True(await e.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)));
|
||||
Assert.Equal(3, e.Current.Sequence); // the stale replay of 1 was skipped
|
||||
}
|
||||
|
||||
// ---- 006 ----
|
||||
private sealed class MarkerScope : IGalaxyBrowseScopeProvider
|
||||
{
|
||||
public IReadOnlyList<string>? ResolveBrowseSubtrees(global::Grpc.Core.ServerCallContext context) => ["x/*"];
|
||||
}
|
||||
|
||||
private static IHost BuildHost(bool scopeBefore, bool scopeAfter, Dictionary<string, string?>? extra = null)
|
||||
{
|
||||
HostApplicationBuilder b = Host.CreateApplicationBuilder();
|
||||
Dictionary<string, string?> cfg = new()
|
||||
{
|
||||
["Galaxy:ConnectionString"] = "Server=127.0.0.1,1;Database=ZB;User Id=x;Password=x;Connect Timeout=1;Encrypt=false",
|
||||
["Galaxy:DashboardRefreshIntervalSeconds"] = "1",
|
||||
};
|
||||
if (extra is not null) { foreach (var kv in extra) cfg[kv.Key] = kv.Value; }
|
||||
b.Configuration.AddInMemoryCollection(cfg);
|
||||
b.Services.AddGrpc();
|
||||
if (scopeBefore) b.Services.AddSingleton<IGalaxyBrowseScopeProvider, MarkerScope>();
|
||||
b.Services.AddZbGalaxyRepository(b.Configuration, "Galaxy");
|
||||
if (scopeAfter) b.Services.AddSingleton<IGalaxyBrowseScopeProvider, MarkerScope>();
|
||||
return b.Build();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddZbGalaxyRepository_builds_resolves_and_starts_and_stops_against_an_unreachable_db()
|
||||
{
|
||||
using IHost host = BuildHost(false, false);
|
||||
IServiceProvider sp = host.Services;
|
||||
Assert.NotNull(sp.GetRequiredService<IGalaxyRepository>());
|
||||
Assert.NotNull(sp.GetRequiredService<IGalaxyDeployNotifier>());
|
||||
Assert.NotNull(sp.GetRequiredService<IGalaxyHierarchySnapshotStore>());
|
||||
IGalaxyHierarchyCache cache = sp.GetRequiredService<IGalaxyHierarchyCache>();
|
||||
Assert.Same(cache, sp.GetRequiredService<IGalaxyHierarchyCache>());
|
||||
Assert.Contains(sp.GetServices<IHostedService>(), s => s is GalaxyHierarchyRefreshService);
|
||||
Assert.NotNull(ActivatorUtilities.CreateInstance<GalaxyRepositoryGrpcService>(sp));
|
||||
Assert.IsType<NullGalaxyBrowseScopeProvider>(sp.GetRequiredService<IGalaxyBrowseScopeProvider>());
|
||||
|
||||
await host.StartAsync();
|
||||
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
|
||||
await cache.WaitForFirstLoadAsync(cts.Token);
|
||||
Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status);
|
||||
await host.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_registered_scope_provider_wins_when_registered_before_the_call()
|
||||
{
|
||||
using IHost host = BuildHost(scopeBefore: true, scopeAfter: false);
|
||||
Assert.IsType<MarkerScope>(host.Services.GetRequiredService<IGalaxyBrowseScopeProvider>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_registered_scope_provider_wins_when_registered_after_the_call()
|
||||
{
|
||||
using IHost host = BuildHost(scopeBefore: false, scopeAfter: true);
|
||||
Assert.IsType<MarkerScope>(host.Services.GetRequiredService<IGalaxyBrowseScopeProvider>());
|
||||
}
|
||||
|
||||
// ---- 008 ----
|
||||
private sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
|
||||
Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_store_logs_once_when_persistence_is_enabled_but_path_is_empty()
|
||||
{
|
||||
CapturingLogger<GalaxyHierarchySnapshotStore> log = new();
|
||||
using GalaxyHierarchySnapshotStore _ = new(
|
||||
Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = true, SnapshotCachePath = "" }), log);
|
||||
Assert.Single(log.Entries, e => e.Level == LogLevel.Information && e.Message.Contains("SnapshotCachePath"));
|
||||
|
||||
CapturingLogger<GalaxyHierarchySnapshotStore> quiet = new();
|
||||
using GalaxyHierarchySnapshotStore __ = new(
|
||||
Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = false }), quiet);
|
||||
Assert.Empty(quiet.Entries);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
| Last reviewed | 2026-11-09 |
|
||||
| Reviewer | Claude |
|
||||
| Commit reviewed | `91fb2b4` |
|
||||
| Open findings | 6 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -171,7 +171,7 @@ Resolved 2026-11-09 in the commit tagged `(scadaproj#42)` (GalaxyRepository `0.2
|
||||
| Severity | Low |
|
||||
| Priority | P3 |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyIndex.cs:106` |
|
||||
|
||||
**Description**
|
||||
@@ -203,7 +203,7 @@ reached (break each cycle at its lowest gobject id) and log a warning naming the
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Resolved 2026-11-09 in the commit tagged `(scadaproj#47)` (GalaxyRepository `0.2.2`, unpublished) — `GalaxyHierarchyIndex.Build` now walks reachability from key 0 after bucketing and re-roots any node that lies on a parent cycle (lowest gobject id first; nodes hanging off the cycle become reachable with it). Regression test `ReviewP3Tests.ParentCycle_members_are_reachable_from_the_root_browse` (A→B→C→A plus a leaf; failed without the fix). Not done: the recommended warning log (the index is a static pure builder with no logger).
|
||||
|
||||
### GalaxyRepository-004 — Stale threshold is hard-coded at 5 min regardless of the refresh interval, and stale projection defeats the memo
|
||||
|
||||
@@ -212,7 +212,7 @@ _Unresolved._
|
||||
| Severity | Low |
|
||||
| Priority | P3 |
|
||||
| Category | Error handling & resilience |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyCache.cs:19` (projection at `:55-64`, `:357`) |
|
||||
|
||||
**Description**
|
||||
@@ -244,7 +244,7 @@ instance, so a status-only projection does not invalidate it.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Resolved 2026-11-09 in the commit tagged `(scadaproj#47)` (GalaxyRepository `0.2.2`, unpublished) — the threshold is now `max(5 min, 3 x DashboardRefreshIntervalSeconds)`, read from an optional `IOptions<GalaxyRepositoryOptions>` constructor parameter (DI supplies it; existing callers keep the 5 min floor). The projected-Stale record is memoized per source entry, so `Current` returns the same instance across reads and the projector memos stay warm. Tests `SlowRefreshInterval_does_not_report_Stale_before_three_intervals` and `Stale_projection_returns_the_same_instance_so_projector_memos_stay_warm`.
|
||||
|
||||
### GalaxyRepository-005 — Deploy-notifier bootstrap can deliver an older event after a newer one
|
||||
|
||||
@@ -253,7 +253,7 @@ _Unresolved._
|
||||
| Severity | Low |
|
||||
| Priority | P3 |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyDeployNotifier.cs:53` |
|
||||
|
||||
**Description**
|
||||
@@ -286,7 +286,7 @@ duplicate case, where the same event arrives both via `Publish` and via bootstra
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Resolved 2026-11-09 in the commit tagged `(scadaproj#47)` (GalaxyRepository `0.2.2`, unpublished) — took the reader-side option: `SubscribeAsync` skips any event whose `Sequence` is not greater than the last one yielded, which also drops the duplicate-delivery case (reading `_latest` before registering would instead lose an event published in between). Test `Subscriber_never_yields_an_older_event_after_a_newer_one` (failed without the fix).
|
||||
|
||||
### GalaxyRepository-006 — No test builds the container from `AddZbGalaxyRepository`
|
||||
|
||||
@@ -295,7 +295,7 @@ _Unresolved._
|
||||
| Severity | Low |
|
||||
| Priority | P3 |
|
||||
| Category | Testing coverage |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/DependencyInjection/GalaxyRepositoryServiceCollectionExtensions.cs:31` |
|
||||
|
||||
**Description**
|
||||
@@ -332,7 +332,7 @@ Add container-building tests that:
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Resolved 2026-11-09 in the commit tagged `(scadaproj#47)` (GalaxyRepository `0.2.2`, unpublished) — new container-building tests in `ReviewP3Tests`: a real `Host` with `AddGrpc()` + `AddZbGalaxyRepository` resolves the repository, notifier, snapshot store, cache (singleton), hosted refresh service and (via `ActivatorUtilities`) `GalaxyRepositoryGrpcService`; starts and stops against an unreachable SQL Server with the cache settling to `Unavailable`; and a host `IGalaxyBrowseScopeProvider` registered before or after the call is the one resolved. Guard test, no behaviour change.
|
||||
|
||||
### GalaxyRepository-007 — Shipped package pulls a vulnerable `System.Security.Cryptography.Pkcs` 9.0.4
|
||||
|
||||
@@ -341,7 +341,7 @@ _Unresolved._
|
||||
| Severity | Low |
|
||||
| Priority | P3 |
|
||||
| Category | Packaging, dependencies & project layout |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `ZB.MOM.WW.GalaxyRepository/Directory.Packages.props:9` |
|
||||
|
||||
**Description**
|
||||
@@ -365,7 +365,7 @@ version. Publish this as a patch release.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Resolved 2026-11-09 in the commit tagged `(scadaproj#47)` (GalaxyRepository `0.2.2`, unpublished) — `System.Security.Cryptography.Pkcs` pinned to the patched `9.0.21` in `Directory.Packages.props` and referenced directly from the library csproj (as scadaproj#22 did for Secrets/Auth). Verified: restore resolves `Pkcs/9.0.21` with no NU1902/NU1903. Consumers see the fix only after the `0.2.2` release is published.
|
||||
|
||||
### GalaxyRepository-008 — Stale documentation: `SnapshotCachePath` default, csproj comment, consumer matrix
|
||||
|
||||
@@ -374,7 +374,7 @@ _Unresolved._
|
||||
| Severity | Low |
|
||||
| Priority | P3 |
|
||||
| Category | Documentation & XML docs |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepositoryOptions.cs:47` |
|
||||
|
||||
**Description**
|
||||
@@ -406,4 +406,4 @@ silently does not exist on that host, and nothing logs that persistence is off.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Resolved 2026-11-09 in the commit tagged `(scadaproj#47)` (GalaxyRepository `0.2.2`, unpublished) — `SnapshotCachePath` XML doc now says an empty path disables persistence; `GalaxyHierarchySnapshotStore` logs once at Information when `PersistSnapshot` is true but the path is empty (test `Snapshot_store_logs_once_when_persistence_is_enabled_but_path_is_empty`); stale csproj comment removed; CLAUDE.md consumer matrix updated (HistorianGateway + mxaccessgw).
|
||||
|
||||
+5
-11
@@ -40,7 +40,7 @@ code-reviews/
|
||||
|
||||
## Summary
|
||||
|
||||
10 of 10 libraries reviewed. 44 pending findings across all libraries.
|
||||
10 of 10 libraries reviewed. 38 pending findings across all libraries.
|
||||
|
||||
Coverage: Secrets, LocalDb, GalaxyRepository and Overview first reviewed, and Auth, Health,
|
||||
Theme, Audit, Configuration and Telemetry re-reviewed, on 2026-11-09 (#15). Each finding also
|
||||
@@ -51,8 +51,8 @@ carries a P1/P2/P3 priority (P1 = Critical/High, P2 = Medium, P3 = Low).
|
||||
| Critical | 0 |
|
||||
| High | 0 |
|
||||
| Medium | 11 |
|
||||
| Low | 33 |
|
||||
| **Total** | **44** |
|
||||
| Low | 27 |
|
||||
| **Total** | **38** |
|
||||
|
||||
## Library Status
|
||||
|
||||
@@ -61,7 +61,7 @@ carries a P1/P2/P3 priority (P1 = Critical/High, P2 = Medium, P3 = Low).
|
||||
| [Audit](Audit/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 5 |
|
||||
| [Auth](Auth/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/1/3 | 4 | 10 |
|
||||
| [Configuration](Configuration/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 4 |
|
||||
| [GalaxyRepository](GalaxyRepository/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/6 | 6 | 8 |
|
||||
| [GalaxyRepository](GalaxyRepository/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 8 |
|
||||
| [Health](Health/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/1 | 1 | 7 |
|
||||
| [LocalDb](LocalDb/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/4/3 | 7 | 7 |
|
||||
| [Overview](Overview/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/3 | 3 | 5 |
|
||||
@@ -100,19 +100,13 @@ _None open._
|
||||
| Secrets-013 | [Secrets](Secrets/findings.md) | UI add/rotate/delete: if the audit write throws after the store write succeeded, the change is reported and audited as a failure and the resolver cache is not evicted |
|
||||
| Secrets-017 | [Secrets](Secrets/findings.md) | `secret set`/`rotate` take the plaintext on argv; the host builder re-parses argv as configuration and can crash with the value in the error |
|
||||
|
||||
### Low (33)
|
||||
### Low (27)
|
||||
|
||||
| ID | Library | Title |
|
||||
|----|---------|-------|
|
||||
| Auth-008 | [Auth](Auth/findings.md) | SQL Server store silently truncates over-length values and matches key ids case-insensitively, diverging from SQLite |
|
||||
| Auth-009 | [Auth](Auth/findings.md) | Canonical username is the first value of a possibly multi-valued attribute, not the value the user matched |
|
||||
| Auth-010 | [Auth](Auth/findings.md) | `ApiKeyVerifier` lets store exceptions escape despite its "only exception path is cancellation" contract |
|
||||
| GalaxyRepository-003 | [GalaxyRepository](GalaxyRepository/findings.md) | Objects in a parent cycle disappear from `BrowseChildren` |
|
||||
| GalaxyRepository-004 | [GalaxyRepository](GalaxyRepository/findings.md) | Stale threshold is hard-coded at 5 min regardless of the refresh interval, and stale projection defeats the memo |
|
||||
| GalaxyRepository-005 | [GalaxyRepository](GalaxyRepository/findings.md) | Deploy-notifier bootstrap can deliver an older event after a newer one |
|
||||
| GalaxyRepository-006 | [GalaxyRepository](GalaxyRepository/findings.md) | No test builds the container from `AddZbGalaxyRepository` |
|
||||
| GalaxyRepository-007 | [GalaxyRepository](GalaxyRepository/findings.md) | Shipped package pulls a vulnerable `System.Security.Cryptography.Pkcs` 9.0.4 |
|
||||
| GalaxyRepository-008 | [GalaxyRepository](GalaxyRepository/findings.md) | Stale documentation: `SnapshotCachePath` default, csproj comment, consumer matrix |
|
||||
| Health-007 | [Health](Health/findings.md) | `AkkaActiveNodeGate` cannot express the probe's `RolePreference`, so gate and `/health/active` can disagree despite the documented guarantee |
|
||||
| LocalDb-005 | [LocalDb](LocalDb/findings.md) | Auto-assigned rowid / AUTOINCREMENT keys collide across nodes and LWW silently drops one row |
|
||||
| LocalDb-006 | [LocalDb](LocalDb/findings.md) | Column names containing `'` are captured but break the apply and snapshot SQL |
|
||||
|
||||
Reference in New Issue
Block a user