Optimierung von Performance und Skalierbarkeit

- Assembly-Versionen auf 2.2.0.0 und 2.8.0.0 aktualisiert.
- Verbesserte Steuerung von `ScheduleScrollUpdate` in SnapPanel.
- Erhöhte Serverkapazität für 300+ Benutzer in EDMIService.
- Einführung von Windows-Authentifizierungs-Caching.
- Neuer atomarer Tabellen-Caching-Mechanismus in EDMIService.
- Lock-Mechanismus in JobListener optimiert (Instanz-Lock).
- Verbesserte Performance durch kürzere Lock-Dauern in SaveDataTables.
- Logging und Snapshot-Veröffentlichung nach Job-Ausführung optimiert.
- Allgemeine Skalierungs- und Performance-Verbesserungen.
This commit is contained in:
Developer01
2026-10-12 10:01:58 +02:00
parent d15d464034
commit 9fbcb97fe4
5 changed files with 124 additions and 64 deletions

View File

@@ -31,5 +31,5 @@ Imports System.Runtime.InteropServices
' indem Sie "*" wie unten gezeigt eingeben: ' indem Sie "*" wie unten gezeigt eingeben:
' <Assembly: AssemblyVersion("1.0.*")> ' <Assembly: AssemblyVersion("1.0.*")>
<Assembly: AssemblyVersion("2.1.0.0")> <Assembly: AssemblyVersion("2.2.0.0")>
<Assembly: AssemblyFileVersion("2.1.0.0")> <Assembly: AssemblyFileVersion("2.2.0.0")>

View File

@@ -78,6 +78,7 @@ Public Class ClassSnapPanel
End Sub End Sub
Private Sub ScheduleScrollUpdate() Private Sub ScheduleScrollUpdate()
If Not IsHandleCreated Then Return
If Not _NeedsScrollUpdate Then If Not _NeedsScrollUpdate Then
_NeedsScrollUpdate = True _NeedsScrollUpdate = True
BeginInvoke(New MethodInvoker(Sub() BeginInvoke(New MethodInvoker(Sub()

View File

@@ -71,16 +71,37 @@ Public Class EDMIService
oThrottling = New ServiceThrottlingBehavior() oThrottling = New ServiceThrottlingBehavior()
Config.Description.Behaviors.Add(oThrottling) Config.Description.Behaviors.Add(oThrottling)
End If End If
Dim oCpuCores As Integer = Environment.ProcessorCount Dim oCpuCores As Integer = Environment.ProcessorCount
oThrottling.MaxConcurrentCalls = Math.Max(200, 32 * oCpuCores) ' war 16×
oThrottling.MaxConcurrentSessions = Math.Max(400, 150 * oCpuCores) ' war 100× ' ← Server-seitige Kapazität für 300+ User
oThrottling.MaxConcurrentCalls = Math.Max(400, 32 * oCpuCores)
oThrottling.MaxConcurrentSessions = Math.Max(600, 150 * oCpuCores)
oThrottling.MaxConcurrentInstances = Integer.MaxValue oThrottling.MaxConcurrentInstances = Integer.MaxValue
' ── Werte für späteres Logging speichern ───────────────────────────── ' ← Server-seitiges Binding: toleriert langsamere Clients (alte DLL!)
Dim oNetTcpBinding = TryCast(oBinding, NetTcpBinding)
If oNetTcpBinding IsNot Nothing Then
' ← Konsistent mit Channel.GetBinding(): CPUs × 50, mind. 500
Dim oMaxConnections = Math.Max(500, oCpuCores * 50)
oNetTcpBinding.SendTimeout = TimeSpan.FromMinutes(5)
oNetTcpBinding.ReceiveTimeout = TimeSpan.FromMinutes(10)
oNetTcpBinding.OpenTimeout = TimeSpan.FromMinutes(2)
oNetTcpBinding.MaxConnections = oMaxConnections
LogConfig?.GetLogger()?.Debug("✔ NetTcpBinding configured | MaxConnections=[{0}] | SendTimeout=[{1}]",
oMaxConnections, oNetTcpBinding.SendTimeout)
End If
' ← NEU: Windows Auth Caching – reduziert DC-Anfragen bei parallelen Verbindungen
Dim oServiceCredentials = Config.Description.Behaviors.Find(Of ServiceCredentials)()
If oServiceCredentials Is Nothing Then
oServiceCredentials = New ServiceCredentials()
Config.Description.Behaviors.Add(oServiceCredentials)
End If
' Gruppenauflösung deaktivieren: weniger DC-Roundtrips pro Verbindung
oServiceCredentials.WindowsAuthentication.IncludeWindowsGroups = False
_ConfiguredEndpoint = oBaseAddress.ToString() _ConfiguredEndpoint = oBaseAddress.ToString()
_ConfiguredMaxCalls = oThrottling.MaxConcurrentCalls _ConfiguredMaxCalls = oThrottling.MaxConcurrentCalls
_ConfiguredMaxSessions = oThrottling.MaxConcurrentSessions _ConfiguredMaxSessions = oThrottling.MaxConcurrentSessions
' ─────────────────────────────────────────────────────────────────────
End Sub End Sub
Public Sub New() Public Sub New()
@@ -201,27 +222,39 @@ Public Class EDMIService
#Region "=== Database ===" #Region "=== Database ==="
Private Shared ReadOnly _CachedTablesLock As New Object() ' ── CachedTables-Snapshot: wird vom JobListener nach dem DatatableJob gesetzt.
Private Shared _CachedTablesSnapshot As List(Of String) = Nothing ' GetCachedTables() liest nur diesen Snapshot – ruft GlobalState nie direkt auf
Private Shared _CachedTablesExpiry As DateTime = DateTime.MinValue ' und blockiert daher nicht, auch wenn der DatatableJob den TableStore sperrt.
Private Class CachedTablesState
Public ReadOnly Tables As String()
Public ReadOnly Timestamp As DateTime
Public Sub New(pTables As String(), pTimestamp As DateTime)
Tables = pTables
Timestamp = pTimestamp
End Sub
End Class
Private Shared _CachedTablesState As CachedTablesState = New CachedTablesState(Array.Empty(Of String)(), DateTime.MinValue)
''' <summary>
''' Wird vom JobListener nach erfolgreichem DatatableJob aufgerufen.
''' Setzt den Snapshot atomar – kein Lock nötig, da Array-Referenzzuweisung auf CLR atomar ist.
''' </summary>
Public Shared Sub PublishCachedTablesSnapshot(pTableNames As IEnumerable(Of String))
Dim oTables = pTableNames.Select(Function(t) t.ToUpper()).ToArray()
Dim oNewState = New CachedTablesState(oTables, DateTime.Now)
' Atomar: Timestamp und Array immer gemeinsam sichtbar
Interlocked.Exchange(_CachedTablesState, oNewState)
LogConfig?.GetLogger()?.Debug("PublishCachedTablesSnapshot: [{0}] tables published at [{1}]",
oTables.Length, oNewState.Timestamp.ToString("HH:mm:ss"))
End Sub
Public Function GetCachedTables() As List(Of String) Implements IEDMIService.GetCachedTables Public Function GetCachedTables() As List(Of String) Implements IEDMIService.GetCachedTables
Try Dim oState = _CachedTablesState ' atomarer Read – eine Referenz
SyncLock _CachedTablesLock _Logger.Debug("GetCachedTables: returning snapshot | Count=[{0}] | SnapshotAge=[{1}s]",
If _CachedTablesSnapshot Is Nothing OrElse DateTime.Now > _CachedTablesExpiry Then oState.Tables.Length,
_Logger.Debug("GetCachedTables: Refreshing cache...") CInt((DateTime.Now - oState.Timestamp).TotalSeconds))
_CachedTablesSnapshot = GlobalState.GetCachedTables() Return oState.Tables.ToList()
_CachedTablesExpiry = DateTime.Now.AddSeconds(30)
_Logger.Debug("GetCachedTables: Cache refreshed | Count=[{0}] | NextExpiry=[{1}]",
_CachedTablesSnapshot.Count, _CachedTablesExpiry.ToString("HH:mm:ss"))
End If
Return _CachedTablesSnapshot.ToList()
End SyncLock
Catch ex As Exception
_Logger.Error("GetCachedTables failed | User=[{0}] | Error=[{1}]", _Username, ex.Message)
_Logger.Error(ex)
Return New List(Of String)
End Try
End Function End Function
Public Function ReturnDatatableFromCache(Name As String, FilterExpression As String, SortByColumn As String) As TableResult Implements IEDMIService.ReturnDatatableFromCache Public Function ReturnDatatableFromCache(Name As String, FilterExpression As String, SortByColumn As String) As TableResult Implements IEDMIService.ReturnDatatableFromCache

View File

@@ -13,7 +13,7 @@ Imports System.Runtime.InteropServices
<Assembly: AssemblyCompany("Digital Data")> <Assembly: AssemblyCompany("Digital Data")>
<Assembly: AssemblyProduct("EDMIService")> <Assembly: AssemblyProduct("EDMIService")>
<Assembly: AssemblyCopyright("2026")> <Assembly: AssemblyCopyright("2026")>
<Assembly: AssemblyTrademark("2.6.0")> <Assembly: AssemblyTrademark("2.8.0")>
<Assembly: ComVisible(False)> <Assembly: ComVisible(False)>
@@ -31,5 +31,5 @@ Imports System.Runtime.InteropServices
' übernehmen, indem Sie "*" eingeben: ' übernehmen, indem Sie "*" eingeben:
' <Assembly: AssemblyVersion("1.0.*")> ' <Assembly: AssemblyVersion("1.0.*")>
<Assembly: AssemblyVersion("2.6.0.0")> <Assembly: AssemblyVersion("2.8.0.0")>
<Assembly: AssemblyFileVersion("2.6.0.0")> <Assembly: AssemblyFileVersion("2.8.0.0")>

View File

@@ -14,8 +14,8 @@ Public Class JobListener
' ✅ Verwaltung mit ReaderWriterLockSlim statt Shadow-Copy ' ✅ Verwaltung mit ReaderWriterLockSlim statt Shadow-Copy
Private ReadOnly _Dataset As DataSet Private ReadOnly _Dataset As DataSet
Private Shared ReadOnly _DatasetLock As New ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion) ' Instanz-Lock statt Shared, damit mehrere Instanzen sich nicht gegenseitig blockieren
Private ReadOnly _DatasetLock As New ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion)
Private ReadOnly _Logger As Logger Private ReadOnly _Logger As Logger
Private ReadOnly _LogConfig As LogConfig Private ReadOnly _LogConfig As LogConfig
Private ReadOnly _MSSQL As MSSQLServer Private ReadOnly _MSSQL As MSSQLServer
@@ -61,7 +61,13 @@ Public Class JobListener
' ✅ Thread-sicherer Zugriff auf Dataset (nur für GetCachedTables) ' ✅ Thread-sicherer Zugriff auf Dataset (nur für GetCachedTables)
Public Function GetCurrentDataset() As DataSet Public Function GetCurrentDataset() As DataSet
' Keine Kopie - nur für Metadaten-Zugriff (Tabellennamen) ' Keine Kopie - nur für Metadaten-Zugriff (Tabellennamen)
' HINWEIS: Aufrufer darf keine strukturellen Änderungen vornehmen!
_DatasetLock.EnterReadLock()
Try
Return _Dataset Return _Dataset
Finally
_DatasetLock.ExitReadLock()
End Try
End Function End Function
Public Overrides Function JobWasExecuted(context As IJobExecutionContext, jobException As JobExecutionException, Optional cancellationToken As CancellationToken = Nothing) As Task Public Overrides Function JobWasExecuted(context As IJobExecutionContext, jobException As JobExecutionException, Optional cancellationToken As CancellationToken = Nothing) As Task
@@ -80,10 +86,25 @@ Public Class JobListener
End Try End Try
Next Next
' ✅ Snapshot NACH allen Tabellen publizieren – direkt aus _Dataset, nicht aus GlobalState.
' Vermeidet Blockierung durch DatatableJob-Lock auf GlobalState/TableStore.
Try
Dim oTableNames As IEnumerable(Of String)
_DatasetLock.EnterReadLock()
Try
oTableNames = _Dataset.Tables.Cast(Of DataTable)().Select(Function(t) t.TableName).ToList()
Finally
_DatasetLock.ExitReadLock()
End Try
EDMIService.PublishCachedTablesSnapshot(oTableNames)
_Logger.Info("✔ CachedTables snapshot published | Count=[{0}]", oTableNames.Count)
Catch ex As Exception
_Logger.Warn("⚠ PublishCachedTablesSnapshot failed: {0}", ex.Message)
End Try
Return MyBase.JobWasExecuted(context, jobException, cancellationToken) Return MyBase.JobWasExecuted(context, jobException, cancellationToken)
End Function End Function
' ✅ OPTIMIERT: SaveDataTables mit direktem Write-Lock
Public Sub SaveDataTables(Result As JobResult.ResultTable) Public Sub SaveDataTables(Result As JobResult.ResultTable)
Dim oStopWatch As New Stopwatch() Dim oStopWatch As New Stopwatch()
@@ -94,51 +115,56 @@ Public Class JobListener
_Logger.Debug("Starting thread-safe update for table [{0}]", oName) _Logger.Debug("Starting thread-safe update for table [{0}]", oName)
' ✅ SCHRITT 1: Write-Lock anfordern (blockiert Leser während Update) ' SCHRITT 1: Neue Tabelle vorbereiten – AUSSERHALB des Locks (teuer!)
oStopWatch.Start() Dim oNewTable As DataTable = oTable.Copy()
oNewTable.TableName = oName
Dim oNewChild As DataTable = Nothing
If Result.ChildTable IsNot Nothing Then
oNewChild = Result.ChildTable.Copy()
oNewChild.TableName = Result.ChildTable.TableName
End If
' SCHRITT 2: Write-Lock nur für den atomaren Swap
oStopWatch.Restart()
_DatasetLock.EnterWriteLock() _DatasetLock.EnterWriteLock()
_Logger.Debug("[Lock] Write-Lock acquired for table [{0}]", oName) _Logger.Debug("[Lock] Write-Lock acquired for table [{0}]", oName)
Try Try
' ✅ SCHRITT 2: Direkte Änderungen im Dataset ' Bestehende Tabelle entfernen
If _Dataset.Tables.Contains(oName) Then If _Dataset.Tables.Contains(oName) Then
_Logger.Debug("Datatable [{0}] already exists, replacing.", oName) Dim oExisting = _Dataset.Tables(oName)
oExisting.Constraints.Clear()
If Result.ChildTable IsNot Nothing Then oExisting.ChildRelations.Clear()
ReplaceExistingTable(oName, oTable, _Dataset, Result.ChildTable) _Dataset.Tables.Remove(oName)
AddRelation(oName, Result.TableRelationColumn, Result.ChildTable.TableName, Result.ChildRelationColumn, _Dataset)
Else
ReplaceExistingTable(oName, oTable, _Dataset)
End If End If
Else _Dataset.Tables.Add(oNewTable)
_Logger.Debug("Datatable [{0}] does not exist yet, adding.", oName)
AddNewTable(oName, oTable, _Dataset) ' Child-Tabelle tauschen
If oNewChild IsNot Nothing Then
If Result.ChildTable IsNot Nothing Then If _Dataset.Tables.Contains(oNewChild.TableName) Then
AddNewTable(Result.ChildTable.TableName, Result.ChildTable, _Dataset) _Dataset.Tables(oNewChild.TableName).Constraints.Clear()
AddRelation(oName, Result.TableRelationColumn, Result.ChildTable.TableName, Result.ChildRelationColumn, _Dataset) _Dataset.Tables(oNewChild.TableName).ChildRelations.Clear()
_Dataset.Tables.Remove(oNewChild.TableName)
End If End If
_Dataset.Tables.Add(oNewChild)
AddRelation(oName, Result.TableRelationColumn, oNewChild.TableName, Result.ChildRelationColumn, _Dataset)
End If End If
_Logger.Info("✔ Dataset successfully updated with table [{0}]", oName)
Finally Finally
' ✅ SCHRITT 3: Write-Lock freigeben (so schnell wie möglich!)
_DatasetLock.ExitWriteLock() _DatasetLock.ExitWriteLock()
oStopWatch.Stop() oStopWatch.Stop()
' ✅ Performance-Warning bei langsamen Updates ' ← Warnung wenn Lock > 50ms (war 100ms – zu tolerant)
If oStopWatch.ElapsedMilliseconds > 500 Then If oStopWatch.ElapsedMilliseconds > 50 Then
_Logger.Warn("⚠ [Performance] Table update took {0}ms (> 500ms threshold)", oStopWatch.ElapsedMilliseconds) _Logger.Warn("⚠ [Performance] Write-Lock held for {0}ms (> 50ms) on table [{1}] | Rows=[{2}]",
oStopWatch.ElapsedMilliseconds, oName, oNewTable.Rows.Count)
Else Else
_Logger.Debug("[Performance] Table update completed in {0}ms", oStopWatch.ElapsedMilliseconds) _Logger.Debug("[Performance] Table update in {0}ms for [{1}]", oStopWatch.ElapsedMilliseconds, oName)
End If End If
End Try End Try
' ✅ SCHRITT 4: Speichern in Historie (außerhalb des Locks) ' SCHRITT 3: Logging + Historie – außerhalb des Locks
Dim oMemoryUsage = GC.GetTotalMemory(True) _Logger.Info("✔ Dataset updated | Table=[{0}] | Rows=[{1}]", oName, oNewTable.Rows.Count)
_Logger.Debug("Estimated memory usage after Job is [{0}]", FormatBytes(oMemoryUsage)) Dim oMemoryUsage = GC.GetTotalMemory(False)
_Logger.Debug("Memory after Job: [{0}]", FormatBytes(oMemoryUsage))
_MSSQL.ExecuteNonQuery($"INSERT INTO TBAPPSERV_CRON_DETAIL_HISTORY (DETAIL_ID) VALUES ({oDetailId})") _MSSQL.ExecuteNonQuery($"INSERT INTO TBAPPSERV_CRON_DETAIL_HISTORY (DETAIL_ID) VALUES ({oDetailId})")
Catch ex As Exception Catch ex As Exception