perf(worker): reverse tag index + memoized views + indexed removals in the handle registry

This commit is contained in:
Joseph Doherty
2026-08-15 16:58:02 -04:00
parent f56798aeb9
commit afec56d03b
3 changed files with 555 additions and 39 deletions
@@ -0,0 +1,272 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Unit tests for <see cref="MxAccessHandleRegistry"/>. The registry gained a
/// reverse (server handle, item definition) index, memoized sorted views, and
/// secondary removal indexes so bulk reads and bulk teardown stop rescanning
/// the whole handle table. These tests pin the observable behaviour those
/// structures must preserve: the same lookup semantics the old linear scan
/// had, snapshot views that only rebuild after a mutation, and removals that
/// leave every index consistent.
/// </summary>
public sealed class MxAccessHandleRegistryTests
{
/// <summary>
/// Verifies the reverse index answers by server handle and ordinal item
/// definition, returns every duplicate registration in ascending item
/// handle order (the order a scan of <see cref="MxAccessHandleRegistry.ItemHandles"/>
/// would have visited them), and misses on the wrong server, a different
/// tag, and a case-differing tag.
/// </summary>
[Fact]
public void GetItemHandlesForDefinition_MatchesServerAndOrdinalTag_InAscendingHandleOrder()
{
MxAccessHandleRegistry registry = new();
registry.RegisterServerHandle(serverHandle: 1, clientName: "client");
registry.RegisterServerHandle(serverHandle: 2, clientName: "client");
// Same tag added twice under server 1 — MXAccess hands back a distinct
// item handle per AddItem, so the index must keep both, lowest first.
registry.RegisterItemHandle(1, itemHandle: 40, "Tank1.PV", string.Empty, hasItemContext: false);
registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false);
registry.RegisterItemHandle(1, itemHandle: 11, "Tank1.SP", string.Empty, hasItemContext: false);
registry.RegisterItemHandle(2, itemHandle: 12, "Tank1.PV", string.Empty, hasItemContext: false);
Assert.Equal(new[] { 10, 40 }, registry.GetItemHandlesForDefinition(1, "Tank1.PV"));
Assert.Equal(new[] { 11 }, registry.GetItemHandlesForDefinition(1, "Tank1.SP"));
Assert.Equal(new[] { 12 }, registry.GetItemHandlesForDefinition(2, "Tank1.PV"));
// Misses: unknown server, unknown tag, and a case-differing tag — the
// read path compares tag addresses ordinally, so casing must not match.
Assert.Empty(registry.GetItemHandlesForDefinition(3, "Tank1.PV"));
Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank9.PV"));
Assert.Empty(registry.GetItemHandlesForDefinition(1, "tank1.pv"));
}
/// <summary>
/// Verifies re-registering an item handle under a new item definition
/// retires the old reverse-index entry instead of leaving a stale hit.
/// </summary>
[Fact]
public void RegisterItemHandle_ReusedHandleWithNewDefinition_RetiresStaleIndexEntry()
{
MxAccessHandleRegistry registry = new();
registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false);
registry.RegisterItemHandle(1, itemHandle: 10, "Tank2.PV", string.Empty, hasItemContext: false);
Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank1.PV"));
Assert.Equal(new[] { 10 }, registry.GetItemHandlesForDefinition(1, "Tank2.PV"));
Assert.Single(registry.ItemHandles);
}
/// <summary>
/// Verifies the reverse index drops handles as they are removed, both for
/// a single item removal and for a whole-server teardown.
/// </summary>
[Fact]
public void GetItemHandlesForDefinition_AfterRemoval_MissesRemovedHandles()
{
MxAccessHandleRegistry registry = new();
registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false);
registry.RegisterItemHandle(1, itemHandle: 11, "Tank1.PV", string.Empty, hasItemContext: false);
registry.RemoveItemHandle(1, itemHandle: 10);
Assert.Equal(new[] { 11 }, registry.GetItemHandlesForDefinition(1, "Tank1.PV"));
registry.UnregisterServerHandle(1);
Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank1.PV"));
}
/// <summary>
/// Verifies each sorted view is memoized: repeated reads hand back the
/// same instance until a mutation of that table invalidates it, and the
/// rebuilt view reflects the mutation while keeping its sort order.
/// </summary>
[Fact]
public void Views_AreMemoizedUntilTheirTableMutates()
{
MxAccessHandleRegistry registry = new();
registry.RegisterServerHandle(serverHandle: 2, clientName: "client");
registry.RegisterServerHandle(serverHandle: 1, clientName: "client");
registry.RegisterItemHandle(1, itemHandle: 11, "Tank1.PV", string.Empty, hasItemContext: false);
registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.SP", string.Empty, hasItemContext: false);
registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Supervisory);
registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain);
IReadOnlyList<RegisteredServerHandle> servers = registry.ServerHandles;
IReadOnlyList<RegisteredItemHandle> items = registry.ItemHandles;
IReadOnlyList<RegisteredAdviceHandle> advices = registry.AdviceHandles;
// No mutation in between: the memoized arrays are handed back as-is.
Assert.Same(servers, registry.ServerHandles);
Assert.Same(items, registry.ItemHandles);
Assert.Same(advices, registry.AdviceHandles);
// Sort orders are unchanged by memoization.
Assert.Equal(new[] { 1, 2 }, Map(servers, handle => handle.ServerHandle));
Assert.Equal(new[] { 10, 11 }, Map(items, handle => handle.ItemHandle));
Assert.Equal(
new[] { MxAccessAdviceKind.Plain, MxAccessAdviceKind.Supervisory },
Map(advices, handle => handle.AdviceKind));
// A mutation of one table invalidates that view only.
registry.RegisterServerHandle(serverHandle: 3, clientName: "client");
Assert.NotSame(servers, registry.ServerHandles);
Assert.Equal(3, registry.ServerHandles.Count);
Assert.Same(items, registry.ItemHandles);
Assert.Same(advices, registry.AdviceHandles);
registry.RemoveItemHandle(1, itemHandle: 11);
Assert.NotSame(items, registry.ItemHandles);
Assert.Single(registry.ItemHandles);
registry.RemoveAdviceHandles(1, itemHandle: 10);
Assert.NotSame(advices, registry.AdviceHandles);
Assert.Empty(registry.AdviceHandles);
}
/// <summary>
/// Verifies removing an item also removes every advice recorded for it and
/// nothing recorded for a sibling item — the bulk unadvise/remove path
/// leans on this to leave a consistent table.
/// </summary>
[Fact]
public void RemoveItemHandle_RemovesOnlyThatItemsAdvices()
{
MxAccessHandleRegistry registry = new();
registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false);
registry.RegisterItemHandle(1, itemHandle: 11, "Tank1.SP", string.Empty, hasItemContext: false);
registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain);
registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Supervisory);
registry.RegisterAdviceHandle(1, itemHandle: 11, MxAccessAdviceKind.Plain);
registry.RemoveItemHandle(1, itemHandle: 10);
Assert.False(registry.ContainsItemHandle(1, 10));
Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));
Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Supervisory));
Assert.True(registry.ContainsItemHandle(1, 11));
Assert.True(registry.ContainsAdviceHandle(1, 11, MxAccessAdviceKind.Plain));
Assert.Single(registry.AdviceHandles);
}
/// <summary>
/// Verifies a bulk unadvise followed by a bulk remove drains the registry
/// completely, so the indexed removals cannot leave orphaned entries that
/// a later lookup would resurrect.
/// </summary>
[Fact]
public void BulkUnadviseThenRemove_LeavesRegistryEmpty()
{
MxAccessHandleRegistry registry = new();
registry.RegisterServerHandle(serverHandle: 1, clientName: "client");
for (int itemHandle = 1; itemHandle <= 50; itemHandle++)
{
registry.RegisterItemHandle(1, itemHandle, "Tank." + itemHandle, string.Empty, hasItemContext: false);
registry.RegisterAdviceHandle(1, itemHandle, MxAccessAdviceKind.Plain);
}
for (int itemHandle = 1; itemHandle <= 50; itemHandle++)
{
registry.RemoveAdviceHandles(1, itemHandle);
}
Assert.Empty(registry.AdviceHandles);
Assert.Equal(50, registry.ItemHandles.Count);
for (int itemHandle = 1; itemHandle <= 50; itemHandle++)
{
registry.RemoveItemHandle(1, itemHandle);
}
Assert.Empty(registry.ItemHandles);
Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank.25"));
Assert.True(registry.ContainsServerHandle(1));
}
/// <summary>
/// Verifies unregistering a server drops its items and advices and leaves
/// every other server's handles untouched, including an advice registered
/// for an item that was never added to the item table.
/// </summary>
[Fact]
public void UnregisterServerHandle_RemovesOnlyThatServersHandles()
{
MxAccessHandleRegistry registry = new();
registry.RegisterServerHandle(serverHandle: 1, clientName: "client-one");
registry.RegisterServerHandle(serverHandle: 2, clientName: "client-two");
registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false);
registry.RegisterItemHandle(1, itemHandle: 11, "Tank1.SP", string.Empty, hasItemContext: false);
registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain);
// Advice without a matching item registration: the old scan removed it by
// server handle, so the per-server index must reach it too.
registry.RegisterAdviceHandle(1, itemHandle: 99, MxAccessAdviceKind.Supervisory);
// Server 2 reuses the same item handle values — packing must keep them apart.
registry.RegisterItemHandle(2, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false);
registry.RegisterAdviceHandle(2, itemHandle: 10, MxAccessAdviceKind.Plain);
registry.UnregisterServerHandle(1);
Assert.False(registry.ContainsServerHandle(1));
Assert.False(registry.ContainsItemHandle(1, 10));
Assert.False(registry.ContainsItemHandle(1, 11));
Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));
Assert.False(registry.ContainsAdviceHandle(1, 99, MxAccessAdviceKind.Supervisory));
Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank1.PV"));
Assert.True(registry.ContainsServerHandle(2));
Assert.True(registry.ContainsItemHandle(2, 10));
Assert.True(registry.ContainsAdviceHandle(2, 10, MxAccessAdviceKind.Plain));
Assert.Equal(new[] { 10 }, registry.GetItemHandlesForDefinition(2, "Tank1.PV"));
Assert.Single(registry.ItemHandles);
Assert.Single(registry.AdviceHandles);
Assert.Single(registry.ServerHandles);
}
/// <summary>
/// Verifies re-registering an advice that is already present does not
/// duplicate it in the per-item removal index — a duplicate would survive
/// the removal that drains that index.
/// </summary>
[Fact]
public void RegisterAdviceHandle_RegisteredTwice_StillRemovedByOneCall()
{
MxAccessHandleRegistry registry = new();
registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain);
registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain);
Assert.Single(registry.AdviceHandles);
registry.RemoveAdviceHandles(1, itemHandle: 10);
Assert.Empty(registry.AdviceHandles);
Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));
// Re-advising after the removal must work off a clean index.
registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain);
Assert.Single(registry.AdviceHandles);
Assert.True(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));
}
private static List<TResult> Map<TSource, TResult>(
IReadOnlyList<TSource> source,
Func<TSource, TResult> selector)
{
List<TResult> mapped = new(source.Count);
for (int index = 0; index < source.Count; index++)
{
mapped.Add(selector(source[index]));
}
return mapped;
}
}