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;
}
}
@@ -4,27 +4,52 @@ using System.Linq;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Tracks the server, item, and advice handles owned by one MXAccess session.
/// </summary>
/// <remarks>
/// The registry is STA-confined by contract: every mutation and every query
/// runs on the session's dedicated STA thread (see
/// <see cref="MxAccessStaSession"/>, which marshals all registry access
/// through that thread), so there is no locking here and the memoized views
/// and secondary indexes below need none either.
/// </remarks>
public sealed class MxAccessHandleRegistry public sealed class MxAccessHandleRegistry
{ {
private readonly Dictionary<int, RegisteredServerHandle> serverHandles = new(); private readonly Dictionary<int, RegisteredServerHandle> serverHandles = new();
private readonly Dictionary<long, RegisteredItemHandle> itemHandles = new(); private readonly Dictionary<long, RegisteredItemHandle> itemHandles = new();
private readonly Dictionary<AdviceHandleKey, RegisteredAdviceHandle> adviceHandles = new(); private readonly Dictionary<AdviceHandleKey, RegisteredAdviceHandle> adviceHandles = new();
// Secondary indexes. Every register/remove method below maintains these so
// lookups by tag and removals stay proportional to what is actually removed
// instead of scanning a whole primary table.
private readonly Dictionary<ItemDefinitionKey, List<int>> itemHandlesByDefinition = new();
private readonly Dictionary<int, HashSet<long>> itemKeysByServer = new();
private readonly Dictionary<long, List<AdviceHandleKey>> adviceKeysByItem = new();
private readonly Dictionary<int, HashSet<long>> advisedItemKeysByServer = new();
// Memoized materializations of the three sorted views. Each is dropped by
// the mutation that invalidates it, so repeated reads between mutations
// hand back the same array instead of re-sorting and re-copying the table.
private RegisteredServerHandle[]? serverHandlesView;
private RegisteredItemHandle[]? itemHandlesView;
private RegisteredAdviceHandle[]? adviceHandlesView;
/// <summary>Gets a read-only list of registered server handles ordered by handle value.</summary> /// <summary>Gets a read-only list of registered server handles ordered by handle value.</summary>
public IReadOnlyList<RegisteredServerHandle> ServerHandles => serverHandles public IReadOnlyList<RegisteredServerHandle> ServerHandles => serverHandlesView ??= serverHandles
.Values .Values
.OrderBy(handle => handle.ServerHandle) .OrderBy(handle => handle.ServerHandle)
.ToArray(); .ToArray();
/// <summary>Gets a read-only list of registered item handles ordered by server handle then item handle.</summary> /// <summary>Gets a read-only list of registered item handles ordered by server handle then item handle.</summary>
public IReadOnlyList<RegisteredItemHandle> ItemHandles => itemHandles public IReadOnlyList<RegisteredItemHandle> ItemHandles => itemHandlesView ??= itemHandles
.Values .Values
.OrderBy(handle => handle.ServerHandle) .OrderBy(handle => handle.ServerHandle)
.ThenBy(handle => handle.ItemHandle) .ThenBy(handle => handle.ItemHandle)
.ToArray(); .ToArray();
/// <summary>Gets a read-only list of registered advice handles ordered by server handle, item handle, and advice kind.</summary> /// <summary>Gets a read-only list of registered advice handles ordered by server handle, item handle, and advice kind.</summary>
public IReadOnlyList<RegisteredAdviceHandle> AdviceHandles => adviceHandles public IReadOnlyList<RegisteredAdviceHandle> AdviceHandles => adviceHandlesView ??= adviceHandles
.Values .Values
.OrderBy(handle => handle.ServerHandle) .OrderBy(handle => handle.ServerHandle)
.ThenBy(handle => handle.ItemHandle) .ThenBy(handle => handle.ItemHandle)
@@ -39,28 +64,38 @@ public sealed class MxAccessHandleRegistry
string clientName) string clientName)
{ {
serverHandles[serverHandle] = new RegisteredServerHandle(serverHandle, clientName); serverHandles[serverHandle] = new RegisteredServerHandle(serverHandle, clientName);
serverHandlesView = null;
} }
/// <summary>Unregisters a server handle and all associated item and advice handles from the registry.</summary> /// <summary>Unregisters a server handle and all associated item and advice handles from the registry.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param> /// <param name="serverHandle">Handle returned by the worker.</param>
public void UnregisterServerHandle(int serverHandle) public void UnregisterServerHandle(int serverHandle)
{ {
serverHandles.Remove(serverHandle); if (serverHandles.Remove(serverHandle))
foreach (long key in itemHandles
.Where(pair => pair.Value.ServerHandle == serverHandle)
.Select(pair => pair.Key)
.ToArray())
{ {
itemHandles.Remove(key); serverHandlesView = null;
} }
foreach (AdviceHandleKey key in adviceHandles // Per-server indexes: the removal cost is proportional to the handles
.Where(pair => pair.Value.ServerHandle == serverHandle) // this server actually owns, not to the size of the whole table.
.Select(pair => pair.Key) if (itemKeysByServer.TryGetValue(serverHandle, out HashSet<long>? ownedItemKeys))
.ToArray())
{ {
adviceHandles.Remove(key); itemKeysByServer.Remove(serverHandle);
foreach (long itemKey in ownedItemKeys)
{
RemoveItemEntry(itemKey);
}
}
if (advisedItemKeysByServer.TryGetValue(serverHandle, out HashSet<long>? advisedItemKeys))
{
advisedItemKeysByServer.Remove(serverHandle);
foreach (long itemKey in advisedItemKeys)
{
RemoveAdviceEntries(itemKey);
}
} }
} }
@@ -85,12 +120,32 @@ public sealed class MxAccessHandleRegistry
string itemContext, string itemContext,
bool hasItemContext) bool hasItemContext)
{ {
itemHandles[CreateItemKey(serverHandle, itemHandle)] = new RegisteredItemHandle( long itemKey = CreateItemKey(serverHandle, itemHandle);
if (itemHandles.TryGetValue(itemKey, out RegisteredItemHandle? existing))
{
// Re-registering the same handle can carry a different item
// definition; drop the stale reverse-index entry first.
RemoveDefinitionIndexEntry(existing);
}
itemHandles[itemKey] = new RegisteredItemHandle(
serverHandle, serverHandle,
itemHandle, itemHandle,
itemDefinition, itemDefinition,
itemContext, itemContext,
hasItemContext); hasItemContext);
AddDefinitionIndexEntry(serverHandle, itemHandle, itemDefinition);
if (!itemKeysByServer.TryGetValue(serverHandle, out HashSet<long>? ownedItemKeys))
{
ownedItemKeys = new HashSet<long>();
itemKeysByServer[serverHandle] = ownedItemKeys;
}
ownedItemKeys.Add(itemKey);
itemHandlesView = null;
} }
/// <summary>Removes an item handle and all associated advice handles from the registry.</summary> /// <summary>Removes an item handle and all associated advice handles from the registry.</summary>
@@ -100,7 +155,19 @@ public sealed class MxAccessHandleRegistry
int serverHandle, int serverHandle,
int itemHandle) int itemHandle)
{ {
itemHandles.Remove(CreateItemKey(serverHandle, itemHandle)); long itemKey = CreateItemKey(serverHandle, itemHandle);
if (RemoveItemEntry(itemKey)
&& itemKeysByServer.TryGetValue(serverHandle, out HashSet<long>? ownedItemKeys))
{
ownedItemKeys.Remove(itemKey);
if (ownedItemKeys.Count == 0)
{
itemKeysByServer.Remove(serverHandle);
}
}
RemoveAdviceHandles(serverHandle, itemHandle); RemoveAdviceHandles(serverHandle, itemHandle);
} }
@@ -115,6 +182,34 @@ public sealed class MxAccessHandleRegistry
return itemHandles.ContainsKey(CreateItemKey(serverHandle, itemHandle)); return itemHandles.ContainsKey(CreateItemKey(serverHandle, itemHandle));
} }
/// <summary>
/// Gets the item handles registered under the specified server handle for
/// the specified item definition, ordered by item handle.
/// </summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemDefinition">Item definition name, compared ordinally.</param>
/// <returns>
/// The matching item handles in ascending order — the same order a scan of
/// <see cref="ItemHandles"/> would visit them — or an empty list when the
/// definition is not registered for that server. MXAccess allows the same
/// tag to be added more than once under one server handle, so this is a
/// list rather than a single handle. The returned list is a live view of
/// the index and is only valid until the next registry mutation.
/// </returns>
public IReadOnlyList<int> GetItemHandlesForDefinition(
int serverHandle,
string itemDefinition)
{
if (itemHandlesByDefinition.TryGetValue(
new ItemDefinitionKey(serverHandle, itemDefinition),
out List<int>? handles))
{
return handles;
}
return Array.Empty<int>();
}
/// <summary>Registers an advice handle with the registry.</summary> /// <summary>Registers an advice handle with the registry.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param> /// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param> /// <param name="itemHandle">Handle returned by the worker.</param>
@@ -125,10 +220,36 @@ public sealed class MxAccessHandleRegistry
MxAccessAdviceKind adviceKind) MxAccessAdviceKind adviceKind)
{ {
AdviceHandleKey key = new(serverHandle, itemHandle, adviceKind); AdviceHandleKey key = new(serverHandle, itemHandle, adviceKind);
if (!adviceHandles.ContainsKey(key))
{
// Only index a key the primary table does not already hold, so the
// per-item list never accumulates duplicates.
long itemKey = CreateItemKey(serverHandle, itemHandle);
if (!adviceKeysByItem.TryGetValue(itemKey, out List<AdviceHandleKey>? itemAdviceKeys))
{
itemAdviceKeys = new List<AdviceHandleKey>(2);
adviceKeysByItem[itemKey] = itemAdviceKeys;
}
itemAdviceKeys.Add(key);
if (!advisedItemKeysByServer.TryGetValue(serverHandle, out HashSet<long>? advisedItemKeys))
{
advisedItemKeys = new HashSet<long>();
advisedItemKeysByServer[serverHandle] = advisedItemKeys;
}
advisedItemKeys.Add(itemKey);
}
adviceHandles[key] = new RegisteredAdviceHandle( adviceHandles[key] = new RegisteredAdviceHandle(
serverHandle, serverHandle,
itemHandle, itemHandle,
adviceKind); adviceKind);
adviceHandlesView = null;
} }
/// <summary>Removes all advice handles for the specified server and item handles from the registry.</summary> /// <summary>Removes all advice handles for the specified server and item handles from the registry.</summary>
@@ -138,12 +259,17 @@ public sealed class MxAccessHandleRegistry
int serverHandle, int serverHandle,
int itemHandle) int itemHandle)
{ {
foreach (AdviceHandleKey key in adviceHandles long itemKey = CreateItemKey(serverHandle, itemHandle);
.Where(pair => pair.Value.ServerHandle == serverHandle && pair.Value.ItemHandle == itemHandle) RemoveAdviceEntries(itemKey);
.Select(pair => pair.Key)
.ToArray()) if (advisedItemKeysByServer.TryGetValue(serverHandle, out HashSet<long>? advisedItemKeys))
{ {
adviceHandles.Remove(key); advisedItemKeys.Remove(itemKey);
if (advisedItemKeys.Count == 0)
{
advisedItemKeysByServer.Remove(serverHandle);
}
} }
} }
@@ -167,6 +293,81 @@ public sealed class MxAccessHandleRegistry
return ((long)serverHandle << 32) | (uint)itemHandle; return ((long)serverHandle << 32) | (uint)itemHandle;
} }
/// <summary>Removes one item from the primary table and the definition index.</summary>
/// <param name="itemKey">Packed server/item key produced by <see cref="CreateItemKey"/>.</param>
/// <returns><see langword="true"/> if an item was removed; otherwise, <see langword="false"/>.</returns>
private bool RemoveItemEntry(long itemKey)
{
if (!itemHandles.TryGetValue(itemKey, out RegisteredItemHandle? registered))
{
return false;
}
itemHandles.Remove(itemKey);
RemoveDefinitionIndexEntry(registered);
itemHandlesView = null;
return true;
}
/// <summary>Removes every advice handle recorded for one item from the primary table and the per-item index.</summary>
/// <param name="itemKey">Packed server/item key produced by <see cref="CreateItemKey"/>.</param>
private void RemoveAdviceEntries(long itemKey)
{
if (!adviceKeysByItem.TryGetValue(itemKey, out List<AdviceHandleKey>? itemAdviceKeys))
{
return;
}
adviceKeysByItem.Remove(itemKey);
foreach (AdviceHandleKey key in itemAdviceKeys)
{
adviceHandles.Remove(key);
}
adviceHandlesView = null;
}
private void AddDefinitionIndexEntry(
int serverHandle,
int itemHandle,
string itemDefinition)
{
ItemDefinitionKey definitionKey = new(serverHandle, itemDefinition);
if (!itemHandlesByDefinition.TryGetValue(definitionKey, out List<int>? handles))
{
handles = new List<int>(1);
itemHandlesByDefinition[definitionKey] = handles;
}
// Kept sorted so callers see the same order a scan of ItemHandles would.
int index = handles.BinarySearch(itemHandle);
if (index < 0)
{
handles.Insert(~index, itemHandle);
}
}
private void RemoveDefinitionIndexEntry(RegisteredItemHandle registered)
{
ItemDefinitionKey definitionKey = new(registered.ServerHandle, registered.ItemDefinition);
if (!itemHandlesByDefinition.TryGetValue(definitionKey, out List<int>? handles))
{
return;
}
handles.Remove(registered.ItemHandle);
if (handles.Count == 0)
{
itemHandlesByDefinition.Remove(definitionKey);
}
}
private readonly struct AdviceHandleKey : IEquatable<AdviceHandleKey> private readonly struct AdviceHandleKey : IEquatable<AdviceHandleKey>
{ {
private readonly int serverHandle; private readonly int serverHandle;
@@ -214,4 +415,50 @@ public sealed class MxAccessHandleRegistry
} }
} }
} }
/// <summary>
/// Reverse-index key: a server handle plus an item definition, compared
/// ordinally to match the tag comparison the read path performs.
/// </summary>
private readonly struct ItemDefinitionKey : IEquatable<ItemDefinitionKey>
{
private readonly int serverHandle;
private readonly string itemDefinition;
/// <summary>Initializes a new instance of the <see cref="ItemDefinitionKey"/> struct.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemDefinition">Item definition name from MXAccess.</param>
public ItemDefinitionKey(
int serverHandle,
string itemDefinition)
{
this.serverHandle = serverHandle;
this.itemDefinition = itemDefinition;
}
/// <inheritdoc />
public bool Equals(ItemDefinitionKey other)
{
return serverHandle == other.serverHandle
&& string.Equals(itemDefinition, other.itemDefinition, StringComparison.Ordinal);
}
/// <inheritdoc />
public override bool Equals(object? obj)
{
return obj is ItemDefinitionKey other && Equals(other);
}
/// <inheritdoc />
public override int GetHashCode()
{
unchecked
{
int hashCode = serverHandle;
hashCode = (hashCode * 397) ^ (itemDefinition ?? string.Empty).GetHashCode();
return hashCode;
}
}
}
} }
@@ -990,23 +990,20 @@ public sealed class MxAccessSession : IDisposable
out int itemHandle, out int itemHandle,
out MxAccessValueCache.CachedValue cachedValue) out MxAccessValueCache.CachedValue cachedValue)
{ {
// Linear scan — bulk-read sizes are small in practice and the registry // Dictionary probe, not a scan: the registry keeps a reverse
// is keyed by handle, not by tag. If profiling ever shows this hot, a // (server handle, item definition) → item handle index, so a bulk read
// reverse tag→handle map can be added on the registry side. // no longer walks the whole item table once per tag. The index matches
foreach (RegisteredItemHandle registered in handleRegistry.ItemHandles) // the tag ordinally and hands the handles back in ascending order — the
{ // same order the previous linear scan over the sorted ItemHandles view
if (registered.ServerHandle != serverHandle) // visited them — so the first advised-and-cached candidate still wins.
{ IReadOnlyList<int> candidates = handleRegistry.GetItemHandlesForDefinition(serverHandle, tagAddress);
continue;
}
if (!string.Equals(registered.ItemDefinition, tagAddress, StringComparison.Ordinal)) for (int index = 0; index < candidates.Count; index++)
{ {
continue; int candidate = candidates[index];
}
if (!handleRegistry.ContainsAdviceHandle(serverHandle, registered.ItemHandle, MxAccessAdviceKind.Plain) if (!handleRegistry.ContainsAdviceHandle(serverHandle, candidate, MxAccessAdviceKind.Plain)
&& !handleRegistry.ContainsAdviceHandle(serverHandle, registered.ItemHandle, MxAccessAdviceKind.Supervisory)) && !handleRegistry.ContainsAdviceHandle(serverHandle, candidate, MxAccessAdviceKind.Supervisory))
{ {
// Tag is added but not advised — no fresh OnDataChange will // Tag is added but not advised — no fresh OnDataChange will
// arrive without us advising. Fall through to the snapshot // arrive without us advising. Fall through to the snapshot
@@ -1014,9 +1011,9 @@ public sealed class MxAccessSession : IDisposable
continue; continue;
} }
if (valueCache.TryGet(serverHandle, registered.ItemHandle, out cachedValue)) if (valueCache.TryGet(serverHandle, candidate, out cachedValue))
{ {
itemHandle = registered.ItemHandle; itemHandle = candidate;
return true; return true;
} }
} }