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)
Private Shared ReadOnly _RecentUsers As New Collections.Concurrent.ConcurrentDictionary(Of String, DateTime)
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
Get
@@ -46,7 +51,6 @@ Public Class EDMIService
Return _ActiveSessions.Values.Distinct().OrderBy(Function(u) u).ToList().AsReadOnly()
End Get
End Property
' ─────────────────────────────────────────────────────────────────────────
Private ReadOnly _Logger As Logger
Private ReadOnly _Debug As Boolean = False
@@ -56,7 +60,19 @@ Public Class EDMIService
Private Shared _ConfiguredEndpoint As String = String.Empty
Private Shared _ConfiguredMaxCalls 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)
@@ -180,13 +196,18 @@ Public Class EDMIService
OrderByDescending(Function(p) p.Value).
Select(Function(p) $"{p.Key} (zuletzt: {p.Value:HH:mm:ss})").
ToList()
Dim oState = _CachedTablesState
Return New Messages.ServiceStatusResponse With {
.ActiveSessionCount = oSnapshot.Length,
.ActiveUsers = oSnapshot.Select(Function(p) p.Value).Distinct().OrderBy(Function(u) u).ToList(),
.ActiveSessions = oSnapshot.ToDictionary(Function(p) p.Key, Function(p) p.Value),
.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 Region

View File

@@ -8,5 +8,10 @@ Namespace Messages
<DataMember(Order:=3)> Public Property ActiveSessions As Dictionary(Of String, String)
<DataMember(Order:=4)> Public Property Timestamp As DateTime
<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 Namespace

View File

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

View File

@@ -1,20 +1,13 @@
Option Explicit On
Imports DigitalData.Modules.Database
Imports DigitalData.Modules.Language.Utils
Imports DigitalData.Modules.Logging
Imports Quartz
Public Class DatatableJob
<DisallowConcurrentExecution> Public Class DatatableJob
Implements IJob
Private Const COMMAND_SQL_TIMEOUT = 240
Private _MSSQL As MSSQLServer
Public Function Execute(context As IJobExecutionContext) As Task Implements IJob.Execute
Dim oJobData = context.MergedJobDataMap
Dim oLogConfig As LogConfig = oJobData.Item("LogConfig")
@@ -26,7 +19,8 @@ Public Class DatatableJob
Dim oResult As New JobResult()
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
Dim oMSSQL As MSSQLServer = oJobData.Item("MSSQL")
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("Connection Id: {0}", oConnectionId)
oStopWatch.Start()
oStopWatch.Restart()
Dim oConnectionString = oMSSQL.Get_ConnectionStringforID(oConnectionId)
Dim oDecryptedConnectionString = MSSQLServer.DecryptConnectionString(oConnectionString)
Dim oConnectionString As String = oMSSQL.Get_ConnectionStringforID(oConnectionId)
Dim oTable = oMSSQL.GetDatatableWithConnection(oSQL, oDecryptedConnectionString, COMMAND_SQL_TIMEOUT)
Dim oTable = oMSSQL.GetDatatableWithConnection(oSQL, oConnectionString, COMMAND_SQL_TIMEOUT)
oTable.TableName = oDatatableName
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()
Dim oView As DataView = Nothing
@@ -107,7 +108,15 @@ Public Class DatatableJob
' Das Ergebnis speichern
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
oLogger.Error(ex)

View File

@@ -1,4 +1,5 @@
Imports System.Collections.Specialized
Imports System.Data.SqlClient
Imports DigitalData.Modules.Database
Imports DigitalData.Modules.Logging
Imports Quartz
@@ -10,14 +11,14 @@ Public Class Scheduler
Inherits BaseClass
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 ReadOnly _JobListener As JobListener
Private ReadOnly _GlobalState As GlobalState ' ✅ NEU
Private ReadOnly _GlobalState As GlobalState
Private ReadOnly _Props = New NameValueCollection From {
{"quartz.serializer.type", "binary"},
{"quartz.threadPool.maxConcurrency", 3}
{"quartz.threadPool.maxConcurrency", 2} ' ← von 3 auf 2
}
Private Const JOB_GROUP As String = "DatatableJobs"
@@ -26,8 +27,17 @@ Public Class Scheduler
MyBase.New(pLogConfig)
_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.JobListener = _JobListener
@@ -37,14 +47,11 @@ Public Class Scheduler
End Sub
Public Async Sub Start()
' Get new scheduler
_Scheduler = Await _Factory.GetScheduler()
' configure it
_Scheduler.ListenerManager.AddJobListener(_JobListener,
GroupMatcher(Of JobKey).GroupEquals(JOB_GROUP))
' start it
Await _Scheduler.Start()
Dim oCronjobs As DataTable = Await GetCronJobs()
@@ -66,10 +73,13 @@ Public Class Scheduler
Dim oJobData As New JobDataMap From {
{"LogConfig", LogConfig},
{"MSSQL", _MSSQL},
{"MSSQL", _SchedulerMSSQL},
{"CronJobId", oGuid},
{"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}"
@@ -83,15 +93,16 @@ Public Class Scheduler
UsingJobData(oJobData).
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
Dim oInitIdentity As String = $"CRON-JOB-INIT-{oGuid}"
oInitTrigger = TriggerBuilder.Create().
WithIdentity(oInitIdentity & "-TRIGGER", JOB_GROUP).
StartNow().
StartAt(DateTimeOffset.Now.AddSeconds(oInitDelaySeconds)). ' ✅ war: StartNow()
Build()
oInitJob = JobBuilder.Create(Of DatatableJob)().
@@ -112,12 +123,11 @@ Public Class Scheduler
Logger.Warn("⚠ Unexpected Error while setting up scheduler: " & ex.Message)
End Try
End Sub
Public Async Function GetCronJobs() As Task(Of DataTable)
Try
Dim oSQL As String = "SELECT * FROM TBAPPSERV_CRON_JOB WITH (NOLOCK) WHERE ACTIVE = 1"
Dim oDatatable As DataTable = Await _MSSQL.GetDatatableAsync(oSQL)
Return oDatatable
Return Await _SchedulerMSSQL.GetDatatableAsync(oSQL) ' ✅ dedizierte Instanz
Catch ex As Exception
Return Nothing
End Try
@@ -126,9 +136,7 @@ Public Class Scheduler
Public Async Function GetCronJobDetails(CronJobId As Integer) As Task(Of DataTable)
Try
Dim oSQL As String = $"SELECT * FROM TBAPPSERV_CRON_DETAIL WITH (NOLOCK) WHERE CRON_ID = {CronJobId}"
Dim oDatatable As DataTable = Await _MSSQL.GetDatatableAsync(oSQL)
Return oDatatable
Return Await _SchedulerMSSQL.GetDatatableAsync(oSQL) ' ✅ dedizierte Instanz
Catch ex As Exception
Return Nothing
End Try
@@ -137,4 +145,4 @@ Public Class Scheduler
Public Async Sub [Stop]()
Await _Scheduler.Shutdown()
End Sub
End Class
End Class

View File

@@ -1,11 +1,12 @@
Imports System.ServiceModel
Imports System.ServiceProcess
Imports DigitalData.Modules.Logging
Imports DigitalData.Modules.Database
Imports DigitalData.Modules
Imports System.ServiceModel.Description
Imports DigitalData.Modules.Config
Imports System.Data.SqlClient
Imports System.ServiceModel
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
Inherits ServiceBase
@@ -187,8 +188,11 @@ Public Class WindowsService
Throw New ArgumentNullException("ConnectionString_ECM")
End If
Dim oMSSQL = New MSSQLServer(pLogConfig, Config.ConnectionString_ECM)
Logger.Info("✔ Database connection to ECM Database established.")
Dim oBuilder As New SqlConnectionStringBuilder(Config.ConnectionString_ECM)
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
End Function
@@ -199,8 +203,11 @@ Public Class WindowsService
Throw New ArgumentNullException("ConnectionString_IDB")
End If
Dim oMSSQL = New MSSQLServer(pLogConfig, Config.ConnectionString_IDB)
Logger.Info("✔ Database connection to IDB Database established.")
Dim oBuilder As New SqlConnectionStringBuilder(Config.ConnectionString_IDB)
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
End Function