Remove quadratic lookups from subscription and metadata paths - #35
Open
ritchiecarroll wants to merge 1 commit into
Open
ritchiecarroll wants to merge 1 commit into
ritchiecarroll wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ConfirmSignalIndexCachecommand — 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.coresources, 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:QueueMeasurementsForProcessingenumerates its parameter ~6 times, andImmediateMeasurements.GetEnumerator()copies the entire cache on every enumeration. Now filters through aHashSet<Guid>and materializes once.AcquireMetadata— post-analysis. Three loops each issued aDataTable.Computecall 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 rescanningPhasorDetail. Join keys are now indexed once. Acronym sets useOrdinalIgnoreCaseto match the case-insensitive comparisonDataTablefilter expressions perform by default; device acronyms are RegEx-restricted to ASCII by the UI.SignalIndexCache(DataSet, SignalIndexCache)andDataSubscriber.FixExpectedMeasurementCounts. Both ranDataTable.Select($"SignalID = '{id}'")per signal againstActiveMeasurements. Replaced with a dictionary built once.UpdateSignalIndexCache— returnsMeasurementKey[]instead ofGuid[]. 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 statics_idCachekeyed by SignalID.AddOrUpdateandTryGetValue.CreateOrUpdate's update factory mutates the existing instance in place and returns it; an instance is never replaced.LookUpBySignalID(k.SignalID)returns the reference-identicalkfor 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
ConcurrentDictionaryprojection was unordered.API change
DataPublisher.UpdateSignalIndexCachechanges return type fromGuid[]toMeasurementKey[]. Checked for consumers: the only callers are the two inSubscriberAdapter. GSF'sGSF.TimeSeries.Transport.DataPublisher.UpdateSignalIndexCacheis a different type and returnsvoid;sttp/dotnetapi'sHandleUpdateSignalIndexCacheis an unrelated private wire handler. TheGuid[]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 winssemantics retained whereSelect(...)[0]was used).Full solution
src/sttp.gsf.slnrebuilt in both Debug and Release, all three projects (sttp.gsf,sttp.gemstone,InteropTest-gsf): 0 errors, no new warnings. Only the 3 pre-existingCS8767nullability warnings fromSubscriberAdapter.cs:35remain, which are unrelated to these changes.🤖 Generated with Claude Code