STTP synchronize metadata optimizations - #32
Open
ritchiecarroll wants to merge 5 commits into
Open
Conversation
Layer 0 of the SynchronizeMetadata optimization: correctness fixes and the
baseline measurement needed to evaluate the layers that follow.
- Fix invalid SQL in the mutual-subscription measurement delete filter. The
condition was built as " AND Internal == 0"; "==" is accepted only by SQLite
and is a syntax error on SQL Server, PostgreSQL, MySQL and Oracle. Reachable
whenever MutualSubscription is enabled and Internal is false.
- Hoist queryProtocolIDSql out of the per-device-row loop. It was rebuilt on
every iteration when synchronizing independent devices.
- Document why the "not safe to overwrite" continue also suppresses child
measurement and phasor synchronization for that device. The behavior is
intended, but it is not evident from the code.
- Track per-phase elapsed time and total statement count, and report both in
the completion status message, e.g.:
Meta-data synchronization completed successfully in 4.21 minutes using
312,847 database statements [devices: 3.10 seconds, measurements:
4.09 minutes, phasors: 5.44 seconds]
This makes the current cost visible in the field and gives the subsequent
optimization layers a concrete before/after number.
No behavioral change beyond the delete-filter fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Layer 1 of the SynchronizeMetadata optimization: the algorithmic change, plus the split of an 810-line method into per-table operations. SynchronizeMetadata previously issued roughly 4D + 3M + 4P statements for D devices, M measurements and P phasors - about 300,000 round trips for a 100k measurement set. Most of those were per-row existence, ownership and record ID probes. Those are replaced by a small number of bulk lookups whose results are diffed in memory. Changes: - Bulk snapshots replace per-row probes. Device existence, ownership and record ID resolution now come from two chunked queries instead of three statements per device. Measurement existence comes from chunked SignalID lookups against the clustered primary key. Phasor existence and record IDs come from one device-scoped query. - Guid values are supplied directly on INSERT. Device.UniqueID and Measurement.SignalID are plain columns with a generated default, so the value can simply be included in the column list. This removes two corrective UPDATE statements per new record - including 'UPDATE Measurement SET SignalID = ... WHERE AlternateTag = <temp guid>', which was a full table scan per new measurement because AlternateTag is large-object typed and cannot be indexed. The temporary alternate tag mechanism is gone with it. - Retired records are deleted in batches rather than one statement per row. This matters most on SQL Server, where an INSTEAD OF DELETE trigger on Measurement issues seven dependent deletes that were previously paid per row. - The ActiveMeasurement view is no longer queried. It is an eleven table join including a cross join, and its result was then filtered with an additional query per candidate row. A single restricted query against Measurement replaces both, carrying forward the view's enabled-state filtering so that measurements of disabled devices are still left alone. - Phasor BaseKV is folded into the main insert and update statements instead of being applied by a follow-up statement. Every phasor update fires a trigger that joins ActiveMeasurement, so halving the statements halves that cost. Structure: logic moves to src/lib/sttp.core/Metadata/ as MetadataSyncContext, MetadataSynchronizer and one operation per table. SynchronizeMetadata retains only connection, transaction and progress concerns and stays protected virtual. Files are registered once in sttp.core.projitems, which both the .NET 4.8 and .NET 9 projects import. Behavior is preserved, including two non-obvious cases that are now documented rather than incidental: - Devices belonging to another connection are still skipped, and skipping them still excludes their measurements and phasors. - Measurement retirement is still not performed when SyncIndependentDevices is enabled. Those devices are not parented to the subscriber, so the previous ActiveMeasurement lookup never matched them and retired measurements were silently retained. A direct query would begin deleting records that have never been deleted before, so the behavior is preserved deliberately and flagged for a separate decision. Batching of write statements is not part of this change; each insert and update is still issued individually. Builds clean on both .NET 4.8/GSF and .NET 9/Gemstone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Layer 2 of the SynchronizeMetadata optimization: combine the individual insert and update statements produced by Layer 1 into far fewer database commands. Device, measurement and phasor writes now accumulate into InsertBatch (multi-row INSERT ... VALUES) and StatementBatch (semicolon separated statements), each flushing once a database-appropriate batch size is reached. For a 100k measurement synchronization on SQL Server this takes the write statement count from roughly 100,000 to a few hundred. MetadataSyncDialect describes the per-database limits that determine batch size: SQL Server 2000 params/command, 1000 rows/VALUES, 250 statements/command PostgreSQL 8000 params/command, 1000 rows/VALUES, 250 statements/command SQLite 900 params/command, 500 rows/VALUES, 250 statements/command MySQL 8000 params/command, 1000 rows/VALUES, single statement only Oracle/other unbatched - identical statement stream to before MySQL is limited to value list batching because multi-statement commands are rejected unless the connection explicitly enabled them. Oracle supports neither form without wrapping statements in an anonymous PL/SQL block, so it falls through to the base dialect and behaves exactly as it did previously. Batched commands bind parameters directly against the command rather than going through the framework parameter helpers. This is required, not merely faster: both frameworks re-parse the statement text on every execution to infer parameter names, which is quadratic in parameter count, and the .NET Framework tokenizer recognizes only space, parenthesis, comma and equals as delimiters, so a parameter adjacent to a semicolon is silently dropped and the call fails with a parameter count mismatch. ANSI string typing is applied on the .NET Framework path to match what those helpers would have done. New MetadataSyncBatchSize connection string setting caps the batch size; zero selects the per-database default and one disables batching entirely, which reproduces Layer 1 behavior for isolating a suspected batching issue. Chunked transactions, which the plan had also proposed for this layer, are deliberately not implemented. Their purpose was to reduce per-statement commit overhead, but batching already reduces the write statement count by roughly three orders of magnitude, leaving little for chunking to recover. It would have cost the all-or-nothing guarantee that UseTransactionForMetadata currently provides, so existing transaction semantics are unchanged. Builds clean on both .NET 4.8/GSF and .NET 9/Gemstone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Layer 3 of the SynchronizeMetadata optimization: replace batched insert statements with SqlBulkCopy for new measurement records on SQL Server. Scope is deliberately limited to measurement inserts. Measurements dominate meta-data volume by two to three orders of magnitude, and the trigger situation on the other two tables argues against bulk copying them: - Device inserts fire a trigger that maintains the Runtime table, which every Runtime* and Iaon* view and ActiveMeasurement.DeviceID depend upon. Device volumes do not justify the risk. - Phasor has no triggers at all but, again, negligible volume. The win is larger than raw throughput suggests. The .NET 9 schema defines an AFTER INSERT trigger on Measurement that runs an unscoped 'UPDATE Measurement SET SignalID = NEWID() WHERE SignalID IS NULL' - a full table scan for every insert statement, with no index supporting the predicate. Batched statements from Layer 2 already cut that from roughly 100,000 scans to several hundred; bulk copy cuts it to one per 10,000 row batch. Because Layer 1 supplies signal IDs from the client, the trigger has nothing left to assign. Triggers are explicitly enabled via SqlBulkCopyOptions.FireTriggers. This costs some throughput but is required for correctness on the .NET Framework schema, where an insert trigger on Measurement maintains change tracking; silently skipping it would leave the rest of the system unaware configuration changed. Since these triggers are statement level on SQL Server, firing them once per bulk operation is correct and still cheap. The staging DataTable takes its schema from the destination table rather than assuming column types. This is not optional: the two supported schemas disagree, storing signal IDs as uniqueidentifier on .NET Framework and nvarchar(36) on .NET 9, and SqlBulkCopy is far less forgiving of type mismatches than a parameterized statement. Values are coerced to the destination column type on the way in. The path is declined, with a status message naming the reason, when the optional audit log schema is installed. Those triggers assign from an arbitrary single row of the inserted pseudo-table and only record correct history for single row writes, so neither firing them nor skipping them is acceptable - the former records misleading history, the latter leaves a silent audit gap. New UseBulkMetadataSync connection string setting, enabled by default, disables the path on request. Builds clean on both .NET 4.8/GSF and .NET 9/Gemstone. SqlBulkCopy required no new dependency on either target: System.Data.SqlClient is in-box on .NET Framework and Microsoft.Data.SqlClient arrives transitively through Gemstone.Data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured against a live SQL Server Express instance, the update path was the remaining bottleneck: a re-synchronization of 193,000 measurements had not finished after ten minutes, while the initial insert of the same set took 24 seconds. The cause was a wrong assumption in the batching layer. Combining update statements into one semicolon separated command reduces round trips, but each statement in that command is still a separate statement, so a statement level trigger fires once for every one of them. The .NET Framework schema's Measurement_UpdateTracker creates and drops two temporary tables and writes two TrackedChange rows every time it fires, so 193,000 updates meant 193,000 trigger invocations no matter how they were batched. Only multi-row INSERT ... VALUES actually collapses trigger work, which is why inserts were already fast. SqlServerBulkUpdate stages a batch into a session temporary table derived from the target table with SELECT TOP 0 ... INTO, bulk copies rows into it, and then issues a single UPDATE ... FROM joined on SignalID. The trigger fires once per batch instead of once per row, and the same change tracking rows are still recorded. That re-synchronization now completes in 27.6 seconds. Measured results, run sequentially without contention, 40 devices and 25,252 measurements from a common source: SQL Server before 540.03 s after 2.62 s 206x SQLite before 109.96 s after 5.66 s 19x Destination databases were compared row for row afterward. On SQL Server: measurement rows differing 0 of 25,157, phasor rows differing 0, device rows differing 1 - and that one is the harness's own subscriber device, created independently in each database and so carrying a different generated UniqueID. On SQLite: 0 differing rows in all three tables. SQLite destinations also match the SQL Server destinations field for field. Delete propagation was verified separately at full scale, 1,033 devices and 193,182 measurements: removed devices, their cascaded measurements, individually removed measurements and removed phasors all disappear from the destination, with no surviving record lost. Change tracking was also compared, since the tracker triggers are how the rest of the system learns configuration changed. The new implementation records 50,355 distinct tracked changes against the old implementation's 75,512. The 25,157 extra entries recorded by the old code all reference a SignalID that does not exist in the Measurement table: it inserted each measurement with a database generated placeholder GUID, firing the tracker, and then overwrote SignalID with the real value, firing it again. No tracked change is recorded by the old code and missed by the new one. 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
DataSubscriber.SynchronizeMetadata()synchronized received metadata one row at a time, issuing roughly4D + 3M + 4Pstatements for D devices, M measurements and P phasors — about 300,000 round trips for a 100k-measurement set. This reworks it into a bulk diff, splits the 810-line method into per-table operations, and adds SQL Server bulk paths.Measured on identical input (40 devices / 25,252 measurements / 4 phasors), run sequentially without contention:
Full scale on SQL Server — 1,038 devices / 228,472 measurements from a production-shaped source: 24.49 s using 512 database statements.
Review guide
Five commits, meant to be read in order. The second is the one that changes behavior and deserves the most attention; the last three are throughput only.
2588fc929fae4ed4src/lib/sttp.core/Metadata/df0cf3830a41902cSqlBulkCopyfor measurement insertsf0157f97What changed, and why it's faster
Per-row probes → bulk snapshots. Device existence, ownership and record-ID resolution went from three statements per device to two chunked queries total; measurement existence from one probe per row to chunked lookups against the clustered key.
GUIDs supplied directly on INSERT. This removes
UPDATE Measurement SET SignalID = … WHERE AlternateTag = <temp guid>, which ran once per new measurement.AlternateTagisvarchar(max)/TEXTin all five schema variants and cannot be indexed, so that statement was a full table scan against a growing table — the dominant cost, and quadratic. The TODO at the old line 3781 assumed an index was the fix; it isn't. The temporary-alternate-tag mechanism is gone with it.ActiveMeasurementno longer queried. It's an 11-table join including a cross join, and its result was then filtered with an extra query per candidate row. One restricted query againstMeasurementreplaces both, carrying the view's enabled-state filtering forward so measurements of disabled devices are still left alone.Batched deletes, which on SQL Server collapse the
INSTEAD OF DELETEtrigger's seven dependent deletes from 7×N to 7 per batch.SQL Server bulk paths for measurement inserts and updates, staging through a temp table. Scoped to measurements deliberately: device inserts drive the trigger that maintains
Runtime, which everyRuntime*/Iaon*view andActiveMeasurement.DeviceIDdepend on, and device/phasor volumes don't justify the risk.Three findings worth your attention
1. Invalid SQL on every backend except SQLite. The mutual-subscription delete filter was built as
" AND Internal == 0".==is accepted only by SQLite and is a syntax error on SQL Server, PostgreSQL, MySQL and Oracle. Reachable wheneverMutualSubscriptionis enabled withInternalfalse. Fixed in2588fc92.2. The old code could not sync two new devices against the .NET 9 schema. There,
Device.UniqueIDhas no default, no GUID trigger, and is nullable but UNIQUE. The old insert-then-fix-up pattern inserted NULL, and SQL Server permits only one NULL under a unique constraint — so the second new device would fail. Supplying the GUID on insert fixes this as a side effect.3. The old code wrote 25,157 garbage change-tracking rows. Old recorded 75,512 distinct
TrackedChangeentries against new's 50,355. Every one of the 25,157 extra entries references aSignalIDthat does not exist inMeasurement— a consequence of inserting with a database-generated placeholder GUID (firing the tracker) and then overwritingSignalIDwith the real value (firing it again). Nothing is recorded by the old code and missed by the new one, so the new behavior is a strict improvement, not a loss.Decisions that need a reviewer's sign-off
Measurement retirement under
SyncIndependentDevicesis unchanged, and it does nothing. Those devices are not parented to the subscriber, so the oldActiveMeasurementlookup never matched them and retired measurements were silently left in place. A direct query would begin deleting records that have never been deleted before, so the existing behavior is preserved deliberately and marked in the code. Whether independently synchronized devices should participate in retirement is a separate call.Chunked transactions were dropped from the plan. Their purpose was reducing per-statement commit overhead, but batching already removes ~99.9% of write statements. Implementing them would have cost the all-or-nothing guarantee
UseTransactionForMetadataprovides today, so existing transaction semantics are unchanged.Batched statements bypass the framework parameter helpers. This is required, not an optimization: both frameworks re-parse the statement text on every execution to infer parameter names, and the .NET Framework tokenizer recognizes only space, parenthesis, comma and equals as delimiters — a parameter adjacent to a semicolon is silently dropped and the call fails on a parameter-count mismatch. Parameters are built directly against the command; ANSI string typing is applied on the .NET Framework path to match what the helpers would have done.
New connection-string settings
MetadataSyncBatchSize00selects the per-database default,1disables batching entirelyUseBulkMetadataSynctrueUseBulkMetadataSyncis declined automatically, with a status message naming the reason, when the optional audit-log schema is installed. Those triggers assign from an arbitrary single row of theinsertedpseudo-table and only record correct history for single-row writes, so neither firing them nor skipping them is acceptable.The completion status message now reports per-phase timings and statement counts:
Verification
Against SQL Server Express 2022 (openPDC schema) and SQLite (
openPDC-InitialDataSet.db), driving the realDataSubscriberthrough a harness that reads metadata with the same queriesDataPublisherissues.Destination databases compared row for row after each run:
¹ Created independently in each database, so it carries a different generated
UniqueID. All synced devices match exactly.SQLite destinations also match the SQL Server destinations field for field, with GUIDs preserved as lowercase text.
Delete propagation, verified at full scale (1,033 devices / 193,182 measurements after removals): deleted devices, their cascaded measurements, individually deleted measurements and deleted phasors all disappear from the destination, with zero surviving records lost.
Not tested: PostgreSQL, MySQL, Oracle. PostgreSQL and MySQL run the same generic path SQLite exercises, differing only in batch limits (MySQL additionally disables multi-statement batching). Oracle deliberately falls through to the unbatched base dialect and produces the same statement stream as before. Note the .NET 9 schema only emits views and triggers under
IfDatabase(SQLite|SqlServer), so these matter only for .NET 4.8/openPDC deployments.Follow-ups, not in this PR
IX_Measurement_DeviceID—Measurement.DeviceIDis unindexed on both schemas despite the foreign key, so every device-scoped read and theDevicedelete cascade scan the table. Highest-value index available.Phasor(PrimaryVoltageID)/Phasor(DestinationPhasorID)— self-referencing FK is unindexed.AuditLog.sqltriggers set-based would fix their multi-row correctness bug and let bulk sync coexist with auditing.DataPublisherusesDataTable.Computeinside loops over the metadata tables — an O(N²) pattern on the publisher side of the same refresh.🤖 Generated with Claude Code