Skip to content

Remove quadratic lookups from subscription and metadata paths - #35

Open
ritchiecarroll wants to merge 1 commit into
developmentfrom
optimizations
Open

ritchiecarroll wants to merge 1 commit into
developmentfrom
optimizations

Conversation

@ritchiecarroll

Copy link
Copy Markdown
Member

Summary

Large subscriptions (100K+ signals) spent minutes in nested linear scans during subscribe and metadata refresh. Because the cached-measurement replay runs on the client's command processing thread, that delay also deferred the subsequent ConfirmSignalIndexCache command — which gates a v2+ subscriber's time to first usable measurement. This was found while diagnosing a multi-minute startup delay reported against the C++ subscriber API.

All changes live in shared sttp.core sources, so both the GSF (.NET 4.8) and Gemstone (.NET 9) targets are covered by each fix.

Changes

HandleSubscribeRequest — cached measurement replay. InputMeasurementKeys.Any(...) ran per cached measurement, so the work was O(cached × subscribed). Two multipliers compounded it: QueueMeasurementsForProcessing enumerates its parameter ~6 times, and ImmediateMeasurements.GetEnumerator() copies the entire cache on every enumeration. Now filters through a HashSet<Guid> and materializes once.

AcquireMetadata — post-analysis. Three loops each issued a DataTable.Compute call per row with a freshly interpolated filter, i.e. an expression parse plus a full table scan per row. The worst was 100K+ measurement rows each rescanning PhasorDetail. Join keys are now indexed once. Acronym sets use OrdinalIgnoreCase to match the case-insensitive comparison DataTable filter expressions perform by default; device acronyms are RegEx-restricted to ASCII by the UI.

SignalIndexCache(DataSet, SignalIndexCache) and DataSubscriber.FixExpectedMeasurementCounts. Both ran DataTable.Select($"SignalID = '{id}'") per signal against ActiveMeasurements. Replaced with a dictionary built once.

UpdateSignalIndexCache — returns MeasurementKey[] instead of Guid[]. Callers were joining those IDs into a multi-megabyte string and re-parsing it to recover keys they already held. This is safe to delete outright rather than optimize:

  • MeasurementKey's constructor is private; every instance is interned into a static s_idCache keyed by SignalID.
  • Nothing ever removes from that cache — only AddOrUpdate and TryGetValue.
  • CreateOrUpdate's update factory mutates the existing instance in place and returns it; an instance is never replaced.
  • Therefore LookUpBySignalID(k.SignalID) returns the reference-identical k for any key in hand, already reflecting the latest Source/ID.

So the round-trip provably could not produce anything the input array didn't already contain. Keys are now collected in subscription order, making the result deterministic where the previous ConcurrentDictionary projection was unordered.

API change

DataPublisher.UpdateSignalIndexCache changes return type from Guid[] to MeasurementKey[]. Checked for consumers: the only callers are the two in SubscriberAdapter. GSF's GSF.TimeSeries.Transport.DataPublisher.UpdateSignalIndexCache is a different type and returns void; sttp/dotnetapi's HandleUpdateSignalIndexCache is an unrelated private wire handler. The Guid[] return existed solely to feed the round-trip removed here.

Verification

Behavior is otherwise unchanged — the predicates, ordering guarantees and null/empty handling are preserved at every site (first row wins semantics retained where Select(...)[0] was used).

Full solution src/sttp.gsf.sln rebuilt in both Debug and Release, all three projects (sttp.gsf, sttp.gemstone, InteropTest-gsf): 0 errors, no new warnings. Only the 3 pre-existing CS8767 nullability warnings from SubscriberAdapter.cs:35 remain, which are unrelated to these changes.

🤖 Generated with Claude Code

Large subscriptions (100K+ signals) spent minutes in nested linear scans
during subscribe and metadata refresh. Because the cached-measurement replay
runs on the client's command processing thread, that delay also deferred the
subsequent ConfirmSignalIndexCache command, which gates a v2+ subscriber's
time to first usable measurement.

DataPublisher.HandleSubscribeRequest: filter the latest-measurement cache
through a HashSet of subscribed signal IDs instead of scanning
InputMeasurementKeys per cached measurement, and materialize the result.
QueueMeasurementsForProcessing enumerates its parameter several times and
ImmediateMeasurements copies the entire cache on every enumeration, so the
lazy query paid for both repeatedly.

DataPublisher.AcquireMetadata: index the device/phasor/measurement join keys
once rather than issuing a DataTable.Compute call per row, each of which
parsed a fresh filter expression and rescanned the table. Acronym sets use
OrdinalIgnoreCase to match the case-insensitive comparison DataTable filter
expressions perform by default.

SignalIndexCache and DataSubscriber.FixExpectedMeasurementCounts: replace the
per-signal DataTable.Select with a dictionary built once over
ActiveMeasurements.

DataPublisher.UpdateSignalIndexCache: return the authorized MeasurementKey[]
instead of Guid[]. Callers were joining those IDs into a multi-megabyte
string and re-parsing it to recover keys they already held. MeasurementKey
interns every instance by signal ID and updates it in place rather than
replacing it, so re-resolving a held key can never yield anything different.
Keys are now collected in subscription order, making the result deterministic
where the previous ConcurrentDictionary projection was unordered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant