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
@@ -4,27 +4,52 @@ using System.Linq;
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
{
private readonly Dictionary<int, RegisteredServerHandle> serverHandles = new();
private readonly Dictionary<long, RegisteredItemHandle> itemHandles = 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>
public IReadOnlyList<RegisteredServerHandle> ServerHandles => serverHandles
public IReadOnlyList<RegisteredServerHandle> ServerHandles => serverHandlesView ??= serverHandles
.Values
.OrderBy(handle => handle.ServerHandle)
.ToArray();
/// <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
.OrderBy(handle => handle.ServerHandle)
.ThenBy(handle => handle.ItemHandle)
.ToArray();
/// <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
.OrderBy(handle => handle.ServerHandle)
.ThenBy(handle => handle.ItemHandle)
@@ -39,28 +64,38 @@ public sealed class MxAccessHandleRegistry
string 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>
/// <param name="serverHandle">Handle returned by the worker.</param>
public void UnregisterServerHandle(int serverHandle)
{
serverHandles.Remove(serverHandle);
foreach (long key in itemHandles
.Where(pair => pair.Value.ServerHandle == serverHandle)
.Select(pair => pair.Key)
.ToArray())
if (serverHandles.Remove(serverHandle))
{
itemHandles.Remove(key);
serverHandlesView = null;
}
foreach (AdviceHandleKey key in adviceHandles
.Where(pair => pair.Value.ServerHandle == serverHandle)
.Select(pair => pair.Key)
.ToArray())
// Per-server indexes: the removal cost is proportional to the handles
// this server actually owns, not to the size of the whole table.
if (itemKeysByServer.TryGetValue(serverHandle, out HashSet<long>? ownedItemKeys))
{
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,
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,
itemHandle,
itemDefinition,
itemContext,
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>
@@ -100,7 +155,19 @@ public sealed class MxAccessHandleRegistry
int serverHandle,
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);
}
@@ -115,6 +182,34 @@ public sealed class MxAccessHandleRegistry
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>
/// <param name="serverHandle">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)
{
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(
serverHandle,
itemHandle,
adviceKind);
adviceHandlesView = null;
}
/// <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 itemHandle)
{
foreach (AdviceHandleKey key in adviceHandles
.Where(pair => pair.Value.ServerHandle == serverHandle && pair.Value.ItemHandle == itemHandle)
.Select(pair => pair.Key)
.ToArray())
long itemKey = CreateItemKey(serverHandle, itemHandle);
RemoveAdviceEntries(itemKey);
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;
}
/// <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 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 MxAccessValueCache.CachedValue cachedValue)
{
// Linear scan — bulk-read sizes are small in practice and the registry
// is keyed by handle, not by tag. If profiling ever shows this hot, a
// reverse tag→handle map can be added on the registry side.
foreach (RegisteredItemHandle registered in handleRegistry.ItemHandles)
// Dictionary probe, not a scan: the registry keeps a reverse
// (server handle, item definition) → item handle index, so a bulk read
// no longer walks the whole item table once per tag. The index matches
// the tag ordinally and hands the handles back in ascending order — the
// same order the previous linear scan over the sorted ItemHandles view
// visited them — so the first advised-and-cached candidate still wins.
IReadOnlyList<int> candidates = handleRegistry.GetItemHandlesForDefinition(serverHandle, tagAddress);
for (int index = 0; index < candidates.Count; index++)
{
if (registered.ServerHandle != serverHandle)
{
continue;
}
int candidate = candidates[index];
if (!string.Equals(registered.ItemDefinition, tagAddress, StringComparison.Ordinal))
{
continue;
}
if (!handleRegistry.ContainsAdviceHandle(serverHandle, registered.ItemHandle, MxAccessAdviceKind.Plain)
&& !handleRegistry.ContainsAdviceHandle(serverHandle, registered.ItemHandle, MxAccessAdviceKind.Supervisory))
if (!handleRegistry.ContainsAdviceHandle(serverHandle, candidate, MxAccessAdviceKind.Plain)
&& !handleRegistry.ContainsAdviceHandle(serverHandle, candidate, MxAccessAdviceKind.Supervisory))
{
// Tag is added but not advised — no fresh OnDataChange will
// arrive without us advising. Fall through to the snapshot
@@ -1014,9 +1011,9 @@ public sealed class MxAccessSession : IDisposable
continue;
}
if (valueCache.TryGet(serverHandle, registered.ItemHandle, out cachedValue))
if (valueCache.TryGet(serverHandle, candidate, out cachedValue))
{
itemHandle = registered.ItemHandle;
itemHandle = candidate;
return true;
}
}