Skip to content

STTP synchronize metadata optimizations - #32

Open
ritchiecarroll wants to merge 5 commits into
masterfrom
claude/synchronize-metadata-optimization-0422b8
Open

STTP synchronize metadata optimizations#32
ritchiecarroll wants to merge 5 commits into
masterfrom
claude/synchronize-metadata-optimization-0422b8

Conversation

@ritchiecarroll

Copy link
Copy Markdown
Member

Summary

DataSubscriber.SynchronizeMetadata() synchronized received metadata one row at a time, issuing roughly 4D + 3M + 4P statements 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:

Backend Before After
SQL Server 540.03 s 2.62 s 206×
SQLite 109.96 s 5.66 s 19×

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.

Commit What
2588fc92 Correctness fixes + per-phase instrumentation
9fae4ed4 Snapshot diff + split into src/lib/sttp.core/Metadata/
df0cf383 Per-database statement batching
0a41902c SqlBulkCopy for measurement inserts
f0157f97 Set-based measurement updates

What 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. AlternateTag is varchar(max)/TEXT in 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.

ActiveMeasurement no 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 against Measurement replaces 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 DELETE trigger'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 every Runtime*/Iaon* view and ActiveMeasurement.DeviceID depend 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 whenever MutualSubscription is enabled with Internal false. Fixed in 2588fc92.

2. The old code could not sync two new devices against the .NET 9 schema. There, Device.UniqueID has 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 TrackedChange entries against new's 50,355. Every one of the 25,157 extra entries references a SignalID that does not exist in Measurement — a consequence of inserting with a database-generated placeholder GUID (firing the tracker) and then overwriting SignalID with 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 SyncIndependentDevices is unchanged, and it does nothing. Those devices are not parented to the subscriber, so the old ActiveMeasurement lookup 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 UseTransactionForMetadata provides 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

Setting Default Meaning
MetadataSyncBatchSize 0 Rows per batched statement; 0 selects the per-database default, 1 disables batching entirely
UseBulkMetadataSync true Allows the SQL Server bulk path

UseBulkMetadataSync is 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 the inserted pseudo-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:

Meta-data synchronization completed successfully in 24.49 seconds using 512 database statements
[devices: 3.03 seconds, measurements: 21.17 seconds, phasors: 199.87 milliseconds]

Verification

Against SQL Server Express 2022 (openPDC schema) and SQLite (openPDC-InitialDataSet.db), driving the real DataSubscriber through a harness that reads metadata with the same queries DataPublisher issues.

Destination databases compared row for row after each run:

SQL Server SQLite
Measurement rows differing 0 of 25,157 0
Device rows differing 1 — the harness's own subscriber device¹ 0
Phasor rows differing 0 0

¹ 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_DeviceIDMeasurement.DeviceID is unindexed on both schemas despite the foreign key, so every device-scoped read and the Device delete cascade scan the table. Highest-value index available.
  • Phasor(PrimaryVoltageID) / Phasor(DestinationPhasorID) — self-referencing FK is unindexed.
  • Rewriting the AuditLog.sql triggers set-based would fix their multi-row correctness bug and let bulk sync coexist with auditing.
  • DataPublisher uses DataTable.Compute inside loops over the metadata tables — an O(N²) pattern on the publisher side of the same refresh.

🤖 Generated with Claude Code

ritchiecarroll and others added 5 commits August 17, 2026 01:14
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>
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