perf(worker): reverse tag index + memoized views + indexed removals in the handle registry
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user