sqlinsight-pro/src/SQLInsight.Agent/Monitoring/MonitoringService.cs
Søren Eggert Lundsteen Olsen 384e1f154f Long operations, full-text rules and statement activity
- Long operations: a one-minute collector reads operations with progress
  from sys.dm_exec_requests (BACKUP, RESTORE, DBCC, REORGANIZE, SHRINK,
  ROLLBACK): command, database, start, percent, expected end, login.
  Stored in insight.LongOperations (migration 0011, RawDays), shown on
  the instance page, in the time window analysis and its PDF. The last
  10 durations per command and database survive restarts; SQLI-OPS-001
  flags a backup/restore running over twice the median.
- Full-text community rules COM-FT-001..005 (metadata only, 2005 syntax):
  disk-full or paused/throttled catalogs, stale manual/off change
  tracking indexes and disabled indexes.
- Statement activity (CPU time / time in plan cache): column in top SQL
  and SQLI-CPU-002 when above SQLInsight:TopSql:ActivityThresholdPercent
  (default 50) for at least an hour. Never clears the plan cache.
- BuiltInCollectors.Create takes a CollectorSettings record.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:07:29 +02:00

135 lines
5.6 KiB
C#

using Microsoft.Extensions.Options;
using SQLInsight.Collectors;
using SQLInsight.Connectivity;
using SQLInsight.Notifications;
using SQLInsight.Repository;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Agent.Monitoring;
/// <summary>
/// The agent's main loop: migrates the repository, then checks every instance on each tick and runs maintenance hourly.
/// </summary>
public sealed partial class MonitoringService(
IOptions<AgentOptions> options,
ISqlConnectionFactory connectionFactory,
RuleEngine ruleEngine,
InsightRepository repository,
TimeProvider timeProvider,
ILoggerFactory loggerFactory,
NotificationDispatcher notifications,
IOptions<NotificationOptions> notificationOptions,
Hub.HubOutbox hubOutbox,
Core.CustomRules.CustomRuleCatalog customRules,
Core.Data.IGroupMemberResolver groupResolver) : BackgroundService
{
private readonly ILogger _logger = loggerFactory.CreateLogger<MonitoringService>();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var settings = options.Value;
await MigrateWithRetryAsync(stoppingToken).ConfigureAwait(false);
var intervals = new Dictionary<string, TimeSpan>(settings.CollectorIntervals, StringComparer.OrdinalIgnoreCase);
var monitors = settings.Instances
.Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(customRules, new CollectorSettings(groupResolver, settings.Privileges.MaxSysadminAccounts, settings.TopSql.ActivityThresholdPercent)), intervals, connectionFactory, ruleEngine, repository,
loggerFactory.CreateLogger($"SQLInsight.Instance.{i.Name}"), notifications, notificationOptions.Value.WebUrl, hubOutbox))
.ToList();
if (monitors.Count == 0)
{
LogNoInstances(_logger);
}
using var throttle = new SemaphoreSlim(Math.Max(1, settings.MaxParallelInstances));
var running = new Dictionary<InstanceMonitor, Task>();
var nextMaintenance = timeProvider.GetUtcNow();
using var timer = new PeriodicTimer(settings.Tick, timeProvider);
do
{
var now = timeProvider.GetUtcNow();
foreach (var monitor in monitors)
{
// A slow instance keeps its own cycle; it never delays the others.
if (running.TryGetValue(monitor, out var task) && !task.IsCompleted || !monitor.IsDue(now))
{
continue;
}
running[monitor] = RunThrottledAsync(monitor, now, throttle, stoppingToken);
}
if (now >= nextMaintenance)
{
nextMaintenance = now.AddHours(1);
await RunMaintenanceAsync(settings, now, stoppingToken).ConfigureAwait(false);
}
}
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
await Task.WhenAll(running.Values).ConfigureAwait(false);
}
private async Task RunThrottledAsync(InstanceMonitor monitor, DateTimeOffset now, SemaphoreSlim throttle, CancellationToken cancellationToken)
{
await throttle.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await monitor.RunCycleAsync(now, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Typically the repository is unreachable. Try again next time.
LogCycleFailed(_logger, ex, monitor.Name);
}
finally
{
throttle.Release();
}
}
private async Task MigrateWithRetryAsync(CancellationToken cancellationToken)
{
for (var attempt = 1; ; attempt++)
{
try
{
var applied = await repository.MigrateAsync(cancellationToken).ConfigureAwait(false);
LogMigrated(_logger, applied);
return;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogMigrationFailed(_logger, ex, attempt);
await Task.Delay(TimeSpan.FromSeconds(Math.Min(60, 5 * attempt)), timeProvider, cancellationToken).ConfigureAwait(false);
}
}
}
private async Task RunMaintenanceAsync(AgentOptions settings, DateTimeOffset now, CancellationToken cancellationToken)
{
try
{
await repository.RunMaintenanceAsync(settings.Retention, now, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogMaintenanceFailed(_logger, ex);
}
}
[LoggerMessage(Level = LogLevel.Warning, Message = "No instances configured under 'SQLInsight:Instances'.")]
private static partial void LogNoInstances(ILogger logger);
[LoggerMessage(Level = LogLevel.Information, Message = "Repository ready ({Applied} migrations applied).")]
private static partial void LogMigrated(ILogger logger, int applied);
[LoggerMessage(Level = LogLevel.Error, Message = "Repository migration failed (attempt {Attempt}); retrying.")]
private static partial void LogMigrationFailed(ILogger logger, Exception exception, int attempt);
[LoggerMessage(Level = LogLevel.Error, Message = "{Instance}: cycle failed")]
private static partial void LogCycleFailed(ILogger logger, Exception exception, string instance);
[LoggerMessage(Level = LogLevel.Error, Message = "Repository maintenance failed.")]
private static partial void LogMaintenanceFailed(ILogger logger, Exception exception);
}