Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseSearchIndex.cs
T
Joseph Doherty db7833609d fix(dashboard): debounced off-dispatcher Browse search, bounded disposal, circuit-aware polling
- Search: the oninput setter scanned the whole Galaxy synchronously per
  keystroke. It now debounces 150ms, runs on the thread pool with
  cancellation (newer keystroke supersedes), and reads a shared singleton
  DashboardBrowseSearchIndex of pre-lowercased references keyed on the
  Galaxy cache Sequence.
- Disposal: DisposeAsync awaited both loops unbounded and the loops caught
  only OCE. Loops now handle faults per iteration (poll faults surface in
  _readError, deploy-notifier faults in a retrying warning), mutate the tree
  on the dispatcher, drain concurrently with a 5s bound, and the CTSs are
  disposed in finally.
- Circuits: DisconnectedCircuitRetentionPeriod is 30s (was the 3 min
  default) and a scoped DashboardCircuitConnectionTracker (CircuitHandler)
  lets the 2s ReadBulk poll skip ticks while the browser is disconnected.
- Reads pass a per-page viewer id to the live-data per-viewer share.
2026-10-22 06:25:46 -04:00

118 lines
4.8 KiB
C#

using ZB.MOM.WW.GalaxyRepository;
using ZB.MOM.WW.GalaxyRepository.Grpc;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Flat, pre-lowercased attribute index behind the Browse page's filter box, shared by
/// every circuit and rebuilt only when the Galaxy cache sequence changes.
/// </summary>
/// <remarks>
/// The filter used to walk every object and attribute of the cached Galaxy, doing two
/// case-insensitive <c>Contains</c> per attribute, synchronously in the property setter
/// bound to <c>oninput</c> — so a large Galaxy was rescanned on the render dispatcher
/// once per keystroke, per circuit. The page now debounces and runs the search off the
/// dispatcher; this index makes each search a single pass of ordinal substring checks
/// over strings lowercased once per Galaxy deploy rather than once per comparison.
/// Lowercasing is invariant-culture, which matches the previous
/// <see cref="StringComparison.OrdinalIgnoreCase"/> behaviour for the ASCII tag names
/// Galaxy uses.
/// </remarks>
public sealed class DashboardBrowseSearchIndex
{
// How often a scan checks its cancellation token. A superseded keystroke stops
// within this many attributes rather than finishing the whole Galaxy.
private const int CancellationCheckInterval = 1024;
private IndexSnapshot? _snapshot;
private int _buildCount;
/// <summary>Gets how many times the index has been (re)built (diagnostic, for tests).</summary>
internal int BuildCount => Volatile.Read(ref _buildCount);
/// <summary>
/// Returns up to <paramref name="limit"/> attributes whose full tag reference or
/// attribute name contains <paramref name="rawQuery"/>, case-insensitively, in
/// Galaxy object order.
/// </summary>
/// <param name="entry">The current Galaxy cache entry; its sequence keys the index.</param>
/// <param name="rawQuery">The filter text as typed; it is trimmed.</param>
/// <param name="limit">Maximum number of matches.</param>
/// <param name="cancellationToken">Cancelled when a newer query supersedes this one.</param>
/// <returns>The matches; empty for a blank query.</returns>
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was cancelled.</exception>
public IReadOnlyList<GalaxyAttribute> Search(
GalaxyHierarchyCacheEntry entry,
string rawQuery,
int limit,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(entry);
string query = (rawQuery ?? string.Empty).Trim().ToLowerInvariant();
if (query.Length == 0 || limit <= 0)
{
return [];
}
IndexedAttribute[] attributes = GetOrBuild(entry, cancellationToken);
List<GalaxyAttribute> matches = [];
for (int i = 0; i < attributes.Length; i++)
{
if (i % CancellationCheckInterval == 0)
{
cancellationToken.ThrowIfCancellationRequested();
}
IndexedAttribute candidate = attributes[i];
if (candidate.FullTagReference.Contains(query, StringComparison.Ordinal)
|| candidate.AttributeName.Contains(query, StringComparison.Ordinal))
{
matches.Add(candidate.Attribute);
if (matches.Count >= limit)
{
break;
}
}
}
return matches;
}
private IndexedAttribute[] GetOrBuild(GalaxyHierarchyCacheEntry entry, CancellationToken cancellationToken)
{
IndexSnapshot? current = Volatile.Read(ref _snapshot);
if (current is not null && current.Sequence == entry.Sequence)
{
return current.Attributes;
}
// A racing build for the same sequence is harmless: the projection is pure, so
// either winner stores equivalent content.
List<IndexedAttribute> attributes = [];
foreach (GalaxyObject galaxyObject in entry.Objects)
{
cancellationToken.ThrowIfCancellationRequested();
foreach (GalaxyAttribute attribute in galaxyObject.Attributes)
{
attributes.Add(new IndexedAttribute(
attribute.FullTagReference.ToLowerInvariant(),
attribute.AttributeName.ToLowerInvariant(),
attribute));
}
}
IndexSnapshot built = new(entry.Sequence, [.. attributes]);
Volatile.Write(ref _snapshot, built);
Interlocked.Increment(ref _buildCount);
return built.Attributes;
}
private sealed record IndexSnapshot(long Sequence, IndexedAttribute[] Attributes);
private readonly record struct IndexedAttribute(
string FullTagReference,
string AttributeName,
GalaxyAttribute Attribute);
}