Files
scadaproj/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/ProjectionMemoBoundTests.cs
T
Joseph Doherty 20cf3bfec7 fix(galaxyrepository): cap the per-entry filter memo at 64 signatures; bump 0.2.1 (scadaproj#41)
Both projectors memoized one full result list per filter signature with no
limit, and the signature is client-controlled (tag_name_glob '*', '**', ...),
so distinct globs retained memory until the next Galaxy deploy. A bounded FIFO
ProjectionMemo<T> now backs both memos; an evicted signature is recomputed.

- tests: ProjectionMemoBoundTests (500 distinct globs per projector, re-query
  after eviction)
- Directory.Build.props 0.2.0 -> 0.2.1 (NOT packed or published)
- code-reviews: GalaxyRepository-001 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:45:32 -05:00

78 lines
3.1 KiB
C#

using ZB.MOM.WW.GalaxyRepository;
using ZB.MOM.WW.GalaxyRepository.Grpc;
namespace ZB.MOM.WW.GalaxyRepository.Tests;
/// <summary>
/// GalaxyRepository-001 (scadaproj#41): the per-entry filter memo was unbounded, so a client
/// issuing distinct <c>tag_name_glob</c> values (<c>*</c>, <c>**</c>, ...) retained one full
/// result list per glob until the next Galaxy deploy. The memo must stay capped.
/// </summary>
public sealed class ProjectionMemoBoundTests
{
private const int DistinctFilters = 500;
[Fact]
public void DiscoverHierarchy_memo_is_bounded_under_distinct_client_globs()
{
GalaxyHierarchyCacheEntry entry = CreateEntry();
for (int i = 1; i <= DistinctFilters; i++)
{
GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = new string('*', i) }, null, 0, 1);
}
Assert.InRange(GalaxyHierarchyProjector.MemoCount(entry), 1, ProjectionMemo<object>.Capacity);
}
[Fact]
public void BrowseChildren_memo_is_bounded_under_distinct_client_globs()
{
GalaxyHierarchyCacheEntry entry = CreateEntry();
for (int i = 1; i <= DistinctFilters; i++)
{
GalaxyBrowseProjector.ProjectChildren(entry, new BrowseChildrenRequest { TagNameGlob = new string('*', i) }, null, 0, 1);
}
Assert.InRange(GalaxyBrowseProjector.MemoCount(entry), 1, ProjectionMemo<object>.Capacity);
}
[Fact]
public void Evicted_signature_is_recomputed_with_the_same_answer()
{
GalaxyHierarchyCacheEntry entry = CreateEntry();
DiscoverHierarchyRequest first = new() { TagNameGlob = "Plant.*" };
GalaxyHierarchyQueryResult before = GalaxyHierarchyProjector.Project(entry, first, null, 0, 10);
for (int i = 1; i <= DistinctFilters; i++)
{
GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = new string('*', i) }, null, 0, 1);
}
GalaxyHierarchyQueryResult after = GalaxyHierarchyProjector.Project(entry, first, null, 0, 10);
Assert.Equal(before.TotalObjectCount, after.TotalObjectCount);
Assert.Equal(before.FilterSignature, after.FilterSignature);
Assert.Equal(before.Objects.Select(o => o.GobjectId), after.Objects.Select(o => o.GobjectId));
}
private static GalaxyHierarchyCacheEntry CreateEntry()
{
GalaxyObject[] objects =
[
new() { GobjectId = 1, ParentGobjectId = 0, IsArea = true, ContainedName = "Plant", BrowseName = "Plant", TagName = "Plant" },
new() { GobjectId = 2, ParentGobjectId = 1, ContainedName = "Pump", BrowseName = "Pump", TagName = "Plant.Pump" },
new() { GobjectId = 3, ParentGobjectId = 1, ContainedName = "Valve", BrowseName = "Valve", TagName = "Plant.Valve" },
];
return GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Sequence = 1,
LastSuccessAt = DateTimeOffset.UtcNow,
Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
ObjectCount = objects.Length,
};
}
}