Verbesserung von Performance und Monitoring

Neue Performance-Metriken in `EDMIService` hinzugefügt, inkl. Felder für Job-Dauer, langsame Queries und Timestamps. `ServiceStatusResponse` um diese Metriken erweitert.
`DatatableJob` optimiert: `<DisallowConcurrentExecution>` hinzugefügt, Logging für langsame Queries (>10s) und Jobs (>30s) verbessert, sowie Gesamt-Dauer-Messung integriert.
`Scheduler` nutzt nun dedizierte MSSQL-Instanz zur Entkopplung von Datenbankoperationen. Thread-Pool-Größe reduziert und gestaffeltes Starten von INIT-Jobs eingeführt.
Connection-Strings in `WindowsService` angepasst: `ApplicationName` spezifisch gesetzt und `MaxPoolSize` erhöht. Allgemeine Logging-Verbesserungen für bessere Skalierbarkeit und Stabilität.
This commit is contained in:
Developer01
2026-10-13 12:04:13 +02:00
parent 205e0ad302
commit 8220852e56
6 changed files with 102 additions and 52 deletions

View File

@@ -34,6 +34,11 @@ Public Class EDMIService
' ► NEU: Letzte Aktivität je Benutzer (für Monitoring auch nach Session-Close) ' ► NEU: Letzte Aktivität je Benutzer (für Monitoring auch nach Session-Close)
Private Shared ReadOnly _RecentUsers As New Collections.Concurrent.ConcurrentDictionary(Of String, DateTime) Private Shared ReadOnly _RecentUsers As New Collections.Concurrent.ConcurrentDictionary(Of String, DateTime)
Private Shared ReadOnly _RecentUserMaxAge As TimeSpan = TimeSpan.FromMinutes(15) Private Shared ReadOnly _RecentUserMaxAge As TimeSpan = TimeSpan.FromMinutes(15)
' ── Performance-Metriken (thread-safe) ───────────────────────────────
Private Shared _LastJobDurationMs As Long = 0
Private Shared _LastJobName As String = String.Empty
Private Shared _LastJobTimestamp As DateTime = DateTime.MinValue
Private Shared _SlowQueryCount As Long = 0
Public Shared ReadOnly Property ActiveSessions As Integer Public Shared ReadOnly Property ActiveSessions As Integer
Get Get
@@ -46,7 +51,6 @@ Public Class EDMIService
Return _ActiveSessions.Values.Distinct().OrderBy(Function(u) u).ToList().AsReadOnly() Return _ActiveSessions.Values.Distinct().OrderBy(Function(u) u).ToList().AsReadOnly()
End Get End Get
End Property End Property
' ─────────────────────────────────────────────────────────────────────────
Private ReadOnly _Logger As Logger Private ReadOnly _Logger As Logger
Private ReadOnly _Debug As Boolean = False Private ReadOnly _Debug As Boolean = False
@@ -56,7 +60,19 @@ Public Class EDMIService
Private Shared _ConfiguredEndpoint As String = String.Empty Private Shared _ConfiguredEndpoint As String = String.Empty
Private Shared _ConfiguredMaxCalls As Integer = 0 Private Shared _ConfiguredMaxCalls As Integer = 0
Private Shared _ConfiguredMaxSessions As Integer = 0 Private Shared _ConfiguredMaxSessions As Integer = 0
' ─────────────────────────────────────────────────────────────────────────
''' <summary>
''' Wird vom JobListener nach jedem DatatableJob aufgerufen.
''' </summary>
Public Shared Sub PublishJobMetrics(pJobName As String, pDurationMs As Long)
Interlocked.Exchange(_LastJobDurationMs, pDurationMs)
Interlocked.Exchange(_LastJobTimestamp, DateTime.Now)
_LastJobName = pJobName ' String-Zuweisung ist atomar auf 64-Bit CLR
If pDurationMs > 10000 Then
Interlocked.Increment(_SlowQueryCount)
End If
End Sub
Public Shared Sub Configure(Config As ServiceConfiguration) Public Shared Sub Configure(Config As ServiceConfiguration)
@@ -180,13 +196,18 @@ Public Class EDMIService
OrderByDescending(Function(p) p.Value). OrderByDescending(Function(p) p.Value).
Select(Function(p) $"{p.Key} (zuletzt: {p.Value:HH:mm:ss})"). Select(Function(p) $"{p.Key} (zuletzt: {p.Value:HH:mm:ss})").
ToList() ToList()
Dim oState = _CachedTablesState
Return New Messages.ServiceStatusResponse With { Return New Messages.ServiceStatusResponse With {
.ActiveSessionCount = oSnapshot.Length, .ActiveSessionCount = oSnapshot.Length,
.ActiveUsers = oSnapshot.Select(Function(p) p.Value).Distinct().OrderBy(Function(u) u).ToList(), .ActiveUsers = oSnapshot.Select(Function(p) p.Value).Distinct().OrderBy(Function(u) u).ToList(),
.ActiveSessions = oSnapshot.ToDictionary(Function(p) p.Key, Function(p) p.Value), .ActiveSessions = oSnapshot.ToDictionary(Function(p) p.Key, Function(p) p.Value),
.Timestamp = DateTime.Now, .Timestamp = DateTime.Now,
.RecentUsers = oRecentUserList ' ◄ NEU – im Response-Objekt ergänzen .RecentUsers = oRecentUserList, ' ◄ NEU – im Response-Objekt ergänzen
.LastJobDurationMs = Interlocked.Read(_LastJobDurationMs),
.LastJobName = _LastJobName,
.LastJobTimestamp = _LastJobTimestamp,
.SlowQueryCount = CInt(Interlocked.Read(_SlowQueryCount)),
.CachedTablesSnapshotAge = CInt((DateTime.Now - oState.Timestamp).TotalSeconds)
} }
End Function End Function
#End Region #End Region

View File

@@ -8,5 +8,10 @@ Namespace Messages
<DataMember(Order:=3)> Public Property ActiveSessions As Dictionary(Of String, String) <DataMember(Order:=3)> Public Property ActiveSessions As Dictionary(Of String, String)
<DataMember(Order:=4)> Public Property Timestamp As DateTime <DataMember(Order:=4)> Public Property Timestamp As DateTime
<DataMember(Order:=5)> Public Property RecentUsers As List(Of String) = New List(Of String)() <DataMember(Order:=5)> Public Property RecentUsers As List(Of String) = New List(Of String)()
<DataMember(Order:=6)> Public Property LastJobDurationMs As Long = 0
<DataMember(Order:=7)> Public Property LastJobName As String = String.Empty
<DataMember(Order:=8)> Public Property LastJobTimestamp As DateTime = DateTime.MinValue
<DataMember(Order:=9)> Public Property SlowQueryCount As Integer = 0 ' Queries > 10s seit Service-Start
<DataMember(Order:=10)> Public Property CachedTablesSnapshotAge As Integer = 0 ' Sekunden seit letztem Snapshot
End Class End Class
End Namespace End Namespace

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.8.0")> <Assembly: AssemblyTrademark("DDEDMIService")>
<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.8.0.0")> <Assembly: AssemblyVersion("2.9.0.0")>
<Assembly: AssemblyFileVersion("2.8.0.0")> <Assembly: AssemblyFileVersion("2.9.0.0")>

View File

@@ -1,20 +1,13 @@
Option Explicit On Option Explicit On
Imports DigitalData.Modules.Database Imports DigitalData.Modules.Database
Imports DigitalData.Modules.Language.Utils
Imports DigitalData.Modules.Logging Imports DigitalData.Modules.Logging
Imports Quartz Imports Quartz
<DisallowConcurrentExecution> Public Class DatatableJob
Public Class DatatableJob
Implements IJob Implements IJob
Private Const COMMAND_SQL_TIMEOUT = 240 Private Const COMMAND_SQL_TIMEOUT = 240
Private _MSSQL As MSSQLServer
Public Function Execute(context As IJobExecutionContext) As Task Implements IJob.Execute Public Function Execute(context As IJobExecutionContext) As Task Implements IJob.Execute
Dim oJobData = context.MergedJobDataMap Dim oJobData = context.MergedJobDataMap
Dim oLogConfig As LogConfig = oJobData.Item("LogConfig") Dim oLogConfig As LogConfig = oJobData.Item("LogConfig")
@@ -26,7 +19,8 @@ Public Class DatatableJob
Dim oResult As New JobResult() Dim oResult As New JobResult()
oLogger.Info("Running Datatable Job [{0}]/[{1}]", context.JobDetail.Key, oCronJobTitle) oLogger.Info("Running Datatable Job [{0}]/[{1}]", context.JobDetail.Key, oCronJobTitle)
Dim oJobStopWatch As New Stopwatch() ' ← Gesamt-Messung für den gesamten Job
oJobStopWatch.Start()
Try Try
Dim oMSSQL As MSSQLServer = oJobData.Item("MSSQL") Dim oMSSQL As MSSQLServer = oJobData.Item("MSSQL")
Dim oCronSQL As String = $"SELECT * FROM TBAPPSERV_CRON_DETAIL WITH (NOLOCK) WHERE CRON_ID = {oCronJobId}" Dim oCronSQL As String = $"SELECT * FROM TBAPPSERV_CRON_DETAIL WITH (NOLOCK) WHERE CRON_ID = {oCronJobId}"
@@ -46,16 +40,23 @@ Public Class DatatableJob
oLogger.Debug("Datatable Name: {0}", oDatatableName) oLogger.Debug("Datatable Name: {0}", oDatatableName)
oLogger.Debug("Connection Id: {0}", oConnectionId) oLogger.Debug("Connection Id: {0}", oConnectionId)
oStopWatch.Start() oStopWatch.Restart()
Dim oConnectionString = oMSSQL.Get_ConnectionStringforID(oConnectionId) Dim oConnectionString As String = oMSSQL.Get_ConnectionStringforID(oConnectionId)
Dim oDecryptedConnectionString = MSSQLServer.DecryptConnectionString(oConnectionString)
Dim oTable = oMSSQL.GetDatatableWithConnection(oSQL, oDecryptedConnectionString, COMMAND_SQL_TIMEOUT) Dim oTable = oMSSQL.GetDatatableWithConnection(oSQL, oConnectionString, COMMAND_SQL_TIMEOUT)
oTable.TableName = oDatatableName oTable.TableName = oDatatableName
oStopWatch.Stop() oStopWatch.Stop()
oLogger.Debug("[Time] Getting Data From DB: {0}ms", oStopWatch.ElapsedMilliseconds) Dim oQueryMs = oStopWatch.ElapsedMilliseconds
' ✅ Schwellwert-Warnung: Query dauert länger als 10s
If oQueryMs > 10000 Then
oLogger.Warn("⚠ SLOW QUERY [{0}] | Datatable=[{1}] | Duration=[{2}ms] | SQL=[{3}]",
oTitle, oDatatableName, oQueryMs, oSQL.Substring(0, Math.Min(200, oSQL.Length)))
Else
oLogger.Debug("[Time] Getting Data From DB: {0}ms", oQueryMs)
End If
oStopWatch.Restart() oStopWatch.Restart()
Dim oView As DataView = Nothing Dim oView As DataView = Nothing
@@ -107,7 +108,15 @@ Public Class DatatableJob
' Das Ergebnis speichern ' Das Ergebnis speichern
context.Result = oResult context.Result = oResult
oLogger.Info("✔✔ Fetched [{0}] Datatables ✔✔", oResult.Tables.Count) oJobStopWatch.Stop()
' ✅ Gesamt-Performance-Log pro Job-Ausführung
Dim oTotalMs = oJobStopWatch.ElapsedMilliseconds
If oTotalMs > 30000 Then
oLogger.Warn("⚠ SLOW JOB [{0}] | Tables=[{1}] | TotalDuration=[{2}ms]",
oCronJobTitle, oResult.Tables.Count, oTotalMs)
Else
oLogger.Info("✔✔ Fetched [{0}] Datatables in [{1}ms] ✔✔", oResult.Tables.Count, oTotalMs)
End If
Catch ex As Exception Catch ex As Exception
oLogger.Error(ex) oLogger.Error(ex)

View File

@@ -1,4 +1,5 @@
Imports System.Collections.Specialized Imports System.Collections.Specialized
Imports System.Data.SqlClient
Imports DigitalData.Modules.Database Imports DigitalData.Modules.Database
Imports DigitalData.Modules.Logging Imports DigitalData.Modules.Logging
Imports Quartz Imports Quartz
@@ -10,14 +11,14 @@ Public Class Scheduler
Inherits BaseClass Inherits BaseClass
Private ReadOnly _Factory As StdSchedulerFactory Private ReadOnly _Factory As StdSchedulerFactory
Private ReadOnly _MSSQL As MSSQLServer Private ReadOnly _SchedulerMSSQL As MSSQLServer ' ✅ Dedizierte Connection für alle Scheduler-Operationen
Private _Scheduler As IScheduler Private _Scheduler As IScheduler
Private ReadOnly _JobListener As JobListener Private ReadOnly _JobListener As JobListener
Private ReadOnly _GlobalState As GlobalState ' ✅ NEU Private ReadOnly _GlobalState As GlobalState
Private ReadOnly _Props = New NameValueCollection From { Private ReadOnly _Props = New NameValueCollection From {
{"quartz.serializer.type", "binary"}, {"quartz.serializer.type", "binary"},
{"quartz.threadPool.maxConcurrency", 3} {"quartz.threadPool.maxConcurrency", 2} ' ← von 3 auf 2
} }
Private Const JOB_GROUP As String = "DatatableJobs" Private Const JOB_GROUP As String = "DatatableJobs"
@@ -26,8 +27,17 @@ Public Class Scheduler
MyBase.New(pLogConfig) MyBase.New(pLogConfig)
_Factory = New StdSchedulerFactory(_Props) _Factory = New StdSchedulerFactory(_Props)
_MSSQL = pMSSQL_Config
_JobListener = New JobListener(pLogConfig, _MSSQL, pTableStore) ' ✅ Separater Connection-Pool ausschließlich für Scheduler-Jobs
' Application Name weicht ab → ADO.NET legt eigenständigen Pool an
' → Scheduler-Queries blockieren nie die WCF-Verbindungen des Service
Dim oBuilder As New SqlConnectionStringBuilder(pMSSQL_Config.CurrentConnectionString)
oBuilder.ApplicationName = "EDMIService-Scheduler"
oBuilder.MaxPoolSize = 2 ' ← NEU: begrenzt SQL Server-Last unter Volllast
_SchedulerMSSQL = New MSSQLServer(pLogConfig, oBuilder.ToString())
Logger.Info("Scheduler: dedicated MSSQLServer instance created | AppName=[{0}]", oBuilder.ApplicationName)
_JobListener = New JobListener(pLogConfig, _SchedulerMSSQL, pTableStore)
_GlobalState = pGlobalState _GlobalState = pGlobalState
_GlobalState.JobListener = _JobListener _GlobalState.JobListener = _JobListener
@@ -37,14 +47,11 @@ Public Class Scheduler
End Sub End Sub
Public Async Sub Start() Public Async Sub Start()
' Get new scheduler
_Scheduler = Await _Factory.GetScheduler() _Scheduler = Await _Factory.GetScheduler()
' configure it
_Scheduler.ListenerManager.AddJobListener(_JobListener, _Scheduler.ListenerManager.AddJobListener(_JobListener,
GroupMatcher(Of JobKey).GroupEquals(JOB_GROUP)) GroupMatcher(Of JobKey).GroupEquals(JOB_GROUP))
' start it
Await _Scheduler.Start() Await _Scheduler.Start()
Dim oCronjobs As DataTable = Await GetCronJobs() Dim oCronjobs As DataTable = Await GetCronJobs()
@@ -66,10 +73,13 @@ Public Class Scheduler
Dim oJobData As New JobDataMap From { Dim oJobData As New JobDataMap From {
{"LogConfig", LogConfig}, {"LogConfig", LogConfig},
{"MSSQL", _MSSQL}, {"MSSQL", _SchedulerMSSQL},
{"CronJobId", oGuid}, {"CronJobId", oGuid},
{"CronJobTitle", oTitle} {"CronJobTitle", oTitle}
} }
' INIT-Job: gestaffelt starten – 15s Abstand statt 10s
' Bei maxConcurrency=2 laufen max. 2 Jobs gleichzeitig
Dim oInitDelaySeconds As Integer = oCronjobs.Rows.IndexOf(oRow) * 15
Dim oIdentity As String = $"CRON-JOB-{oGuid}" Dim oIdentity As String = $"CRON-JOB-{oGuid}"
@@ -83,15 +93,16 @@ Public Class Scheduler
UsingJobData(oJobData). UsingJobData(oJobData).
Build() Build()
Await _Scheduler.ScheduleJob(oJob, oCronTrigger) Dim oNextFireTime As DateTimeOffset? = Await _Scheduler.ScheduleJob(oJob, oCronTrigger)
Logger.Info("Cron Job [{0}] -> next scheduled run: {1}", oTitle,
If(oNextFireTime.HasValue, oNextFireTime.Value.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss"), "unknown"))
' Also create variant of the job that runs immediately, once ' Also create variant of the job that runs immediately, once
Dim oInitIdentity As String = $"CRON-JOB-INIT-{oGuid}" Dim oInitIdentity As String = $"CRON-JOB-INIT-{oGuid}"
oInitTrigger = TriggerBuilder.Create(). oInitTrigger = TriggerBuilder.Create().
WithIdentity(oInitIdentity & "-TRIGGER", JOB_GROUP). WithIdentity(oInitIdentity & "-TRIGGER", JOB_GROUP).
StartNow(). StartAt(DateTimeOffset.Now.AddSeconds(oInitDelaySeconds)). ' ✅ war: StartNow()
Build() Build()
oInitJob = JobBuilder.Create(Of DatatableJob)(). oInitJob = JobBuilder.Create(Of DatatableJob)().
@@ -112,12 +123,11 @@ Public Class Scheduler
Logger.Warn("⚠ Unexpected Error while setting up scheduler: " & ex.Message) Logger.Warn("⚠ Unexpected Error while setting up scheduler: " & ex.Message)
End Try End Try
End Sub End Sub
Public Async Function GetCronJobs() As Task(Of DataTable) Public Async Function GetCronJobs() As Task(Of DataTable)
Try Try
Dim oSQL As String = "SELECT * FROM TBAPPSERV_CRON_JOB WITH (NOLOCK) WHERE ACTIVE = 1" Dim oSQL As String = "SELECT * FROM TBAPPSERV_CRON_JOB WITH (NOLOCK) WHERE ACTIVE = 1"
Dim oDatatable As DataTable = Await _MSSQL.GetDatatableAsync(oSQL) Return Await _SchedulerMSSQL.GetDatatableAsync(oSQL) ' ✅ dedizierte Instanz
Return oDatatable
Catch ex As Exception Catch ex As Exception
Return Nothing Return Nothing
End Try End Try
@@ -126,9 +136,7 @@ Public Class Scheduler
Public Async Function GetCronJobDetails(CronJobId As Integer) As Task(Of DataTable) Public Async Function GetCronJobDetails(CronJobId As Integer) As Task(Of DataTable)
Try Try
Dim oSQL As String = $"SELECT * FROM TBAPPSERV_CRON_DETAIL WITH (NOLOCK) WHERE CRON_ID = {CronJobId}" Dim oSQL As String = $"SELECT * FROM TBAPPSERV_CRON_DETAIL WITH (NOLOCK) WHERE CRON_ID = {CronJobId}"
Dim oDatatable As DataTable = Await _MSSQL.GetDatatableAsync(oSQL) Return Await _SchedulerMSSQL.GetDatatableAsync(oSQL) ' ✅ dedizierte Instanz
Return oDatatable
Catch ex As Exception Catch ex As Exception
Return Nothing Return Nothing
End Try End Try
@@ -137,4 +145,4 @@ Public Class Scheduler
Public Async Sub [Stop]() Public Async Sub [Stop]()
Await _Scheduler.Shutdown() Await _Scheduler.Shutdown()
End Sub End Sub
End Class End Class

View File

@@ -1,11 +1,12 @@
Imports System.ServiceModel Imports System.Data.SqlClient
Imports System.ServiceProcess Imports System.ServiceModel
Imports DigitalData.Modules.Logging
Imports DigitalData.Modules.Database
Imports DigitalData.Modules
Imports System.ServiceModel.Description
Imports DigitalData.Modules.Config
Imports System.ServiceModel.Channels Imports System.ServiceModel.Channels
Imports System.ServiceModel.Description
Imports System.ServiceProcess
Imports DigitalData.Modules
Imports DigitalData.Modules.Config
Imports DigitalData.Modules.Database
Imports DigitalData.Modules.Logging
Public Class WindowsService Public Class WindowsService
Inherits ServiceBase Inherits ServiceBase
@@ -187,8 +188,11 @@ Public Class WindowsService
Throw New ArgumentNullException("ConnectionString_ECM") Throw New ArgumentNullException("ConnectionString_ECM")
End If End If
Dim oMSSQL = New MSSQLServer(pLogConfig, Config.ConnectionString_ECM) Dim oBuilder As New SqlConnectionStringBuilder(Config.ConnectionString_ECM)
Logger.Info("✔ Database connection to ECM Database established.") oBuilder.ApplicationName = "DD_EDMIAppService"
oBuilder.MaxPoolSize = Math.Max(50, oBuilder.MaxPoolSize) ' ← nie kleiner als 50 setzen
Dim oMSSQL = New MSSQLServer(pLogConfig, oBuilder.ToString())
Logger.Info("✔ Database connection to ECM Database established | MaxPool=[{0}]", oBuilder.MaxPoolSize)
Return oMSSQL Return oMSSQL
End Function End Function
@@ -199,8 +203,11 @@ Public Class WindowsService
Throw New ArgumentNullException("ConnectionString_IDB") Throw New ArgumentNullException("ConnectionString_IDB")
End If End If
Dim oMSSQL = New MSSQLServer(pLogConfig, Config.ConnectionString_IDB) Dim oBuilder As New SqlConnectionStringBuilder(Config.ConnectionString_IDB)
Logger.Info("✔ Database connection to IDB Database established.") oBuilder.ApplicationName = "DD_EDMIAppService-IDB"
oBuilder.MaxPoolSize = Math.Max(20, oBuilder.MaxPoolSize) ' ← nie kleiner als 20 setzen
Dim oMSSQL = New MSSQLServer(pLogConfig, oBuilder.ToString())
Logger.Info("✔ Database connection to IDB Database established | MaxPool=[{0}]", oBuilder.MaxPoolSize)
Return oMSSQL Return oMSSQL
End Function End Function