sqlinsight-pro/tests/SQLInsight.Core.Tests/WindowsServerTests.cs
Søren Eggert Lundsteen Olsen 2345b199fa Review 1.5: disk forecasts, 24-hour event counts, performance gaps and per-user tasks in Core
- DiskForecast.EnoughHistory: false for "ingen målinger endnu" and "for lidt historik",
  so a rule can say "ikke målbar" instead of "fyldes ikke op". Days are counted from
  now (a last point older than its hour is subtracted); FullAt takes the time the
  forecast was made; Describe says "fuld nu" at 0 days.
- ServerEventGroup.Buckets: counts per clock hour, and Recent sums only the hours with
  events in the last 24 hours (FirstAt within the window), so a recurring error no
  longer adds up from when it was first seen. Older groups without buckets still merge.
- ServerPerformanceMath.Sample takes the interval: after a gap of more than three
  intervals CPU and pages/s are null rather than the gap's average stamped now.
  Commit percent is computed in floating point.
- TaskResults.IsUserOrUpdaterTask (a user's SID in the name, OneDrive/Edge/Google
  updaters, User_Feed_Synchronization), HasUserSid and Display (SID replaced by a
  short hash for titles and subjects).
- Tests: forecast from real history, irregular sampling, identical timestamps,
  a shrunk volume, days from now; an error every ten hours over three days; a
  sample after a gap.

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

433 lines
19 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using SQLInsight.Core.Servers;
namespace SQLInsight.Core.Tests;
public class DiskForecastTests
{
private static readonly DateTimeOffset Now = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero);
/// <summary>Hourly samples for <paramref name="days"/> days back, free space from a function of the age in days.</summary>
private static List<DiskHistoryPoint> History(int days, Func<double, double> free, double size = 100) =>
Enumerable.Range(0, (days * 24) + 1).Select(h => Now.AddHours(-h)).Select(at => new DiskHistoryPoint(at, size, free((Now - at).TotalDays))).Reverse().ToList();
[Fact]
public void Steady_growth_gives_days_until_full()
{
// 1 GB per day used, 20 GB free now: full in 20 days.
var forecast = DiskForecaster.Forecast(@"D:\", History(14, age => 20 + age), Now);
Assert.NotNull(forecast.DaysUntilFull);
Assert.Equal(20, forecast.DaysUntilFull!.Value, 1);
Assert.Equal(1, forecast.GrowthGbPerDay, 3);
Assert.Equal(1, forecast.Fit, 3);
Assert.Equal("fuld om ca. 20 dage", forecast.Describe());
Assert.Equal(Now.AddDays(20).Date, forecast.FullAt(Now)!.Value.Date);
}
[Fact]
public void Noise_around_a_trend_still_forecasts()
{
var random = new Random(7);
var forecast = DiskForecaster.Forecast(@"D:\", History(14, age => 30 + (2 * age) + (random.NextDouble() - 0.5)), Now);
Assert.InRange(forecast.DaysUntilFull!.Value, 14, 16);
}
[Fact]
public void A_flat_or_shrinking_volume_has_no_forecast()
{
Assert.Null(DiskForecaster.Forecast(@"C:\", History(14, _ => 40), Now).DaysUntilFull);
var emptying = DiskForecaster.Forecast(@"C:\", History(14, age => 40 - age), Now);
Assert.Null(emptying.DaysUntilFull);
Assert.Equal("fyldes ikke op", emptying.Note);
}
[Fact]
public void A_sawtooth_of_backups_written_and_deleted_is_not_a_trend()
{
// Fills 10 GB every night and is cleaned up every morning.
var forecast = DiskForecaster.Forecast(@"E:\", History(14, age => 50 - ((age % 1) * 10)), Now);
Assert.Null(forecast.DaysUntilFull);
}
[Fact]
public void Too_little_history_says_so()
{
var forecast = DiskForecaster.Forecast(@"D:\", History(1, age => 20 + (5 * age)), Now);
Assert.Null(forecast.DaysUntilFull);
Assert.Contains("for lidt historik", forecast.Note, StringComparison.Ordinal);
}
[Fact]
public void An_extended_volume_starts_a_new_trend()
{
// Was 100 GB and filling fast until 5 days ago; extended to 200 GB and now flat.
var history = History(14, age => age > 5 ? 5 + age : 100).Select(p => (Now - p.At).TotalDays > 5 ? p : p with { SizeGb = 200 }).ToList();
var forecast = DiskForecaster.Forecast(@"D:\", history, Now);
Assert.Null(forecast.DaysUntilFull);
Assert.InRange(forecast.HistoryDays, 4.9, 5.1);
}
[Fact]
public void A_full_volume_is_full_now_and_far_futures_are_not_forecasts()
{
Assert.Equal(0, DiskForecaster.Forecast(@"D:\", History(14, age => Math.Max(0, age - 1)), Now).DaysUntilFull);
var slow = DiskForecaster.Forecast(@"D:\", History(14, age => 500 + (0.5 * age)), Now, new DiskForecastSettings());
Assert.Null(slow.DaysUntilFull);
Assert.Equal("fuld om mere end et år", slow.Note);
}
[Fact]
public void Samples_older_than_the_history_window_are_ignored()
{
// Fast growth 20–40 days ago, flat for the last 14.
var history = Enumerable.Range(0, 40 * 24).Select(h => Now.AddHours(-h))
.Select(at => new DiskHistoryPoint(at, 100, (Now - at).TotalDays > 20 ? (Now - at).TotalDays : 30)).Reverse().ToList();
Assert.Null(DiskForecaster.Forecast(@"D:\", history, Now).DaysUntilFull);
}
[Fact]
public void Only_too_little_history_is_not_enough_history()
{
Assert.False(DiskForecaster.Forecast(@"D:\", [], Now).EnoughHistory);
Assert.False(DiskForecaster.Forecast(@"D:\", History(1, age => 20 + age), Now).EnoughHistory);
Assert.True(DiskForecaster.Forecast(@"D:\", History(14, _ => 40), Now).EnoughHistory); // flat: judged, and not filling up
Assert.True(DiskForecaster.Forecast(@"D:\", History(14, age => 20 + age), Now).EnoughHistory);
}
[Fact]
public void Days_are_counted_from_now_not_from_the_last_sample()
{
// 1 GB per day used; the last sample is two days old and had 20 GB free: full in 18 days from now.
var history = History(14, age => 20 + age).Select(p => p with { At = p.At.AddDays(-2) }).ToList();
var forecast = DiskForecaster.Forecast(@"D:\", history, Now);
Assert.Equal(18, forecast.DaysUntilFull!.Value, 1);
Assert.Equal(Now.AddDays(18).Date, forecast.FullAt(Now)!.Value.Date);
}
[Fact]
public void Irregular_sampling_gives_the_same_trend()
{
// Samples at uneven times (the agent was down now and then), 2 GB per day used, 30 GB free now.
var random = new Random(3);
var at = Now.AddDays(-14);
var history = new List<DiskHistoryPoint>();
while (at <= Now)
{
history.Add(new DiskHistoryPoint(at, 100, 30 + (2 * (Now - at).TotalDays)));
at = at.AddMinutes(15 + random.Next(0, 600));
}
history.Add(new DiskHistoryPoint(Now, 100, 30));
var forecast = DiskForecaster.Forecast(@"D:\", history, Now);
Assert.Equal(2, forecast.GrowthGbPerDay, 6);
Assert.Equal(15, forecast.DaysUntilFull!.Value, 3);
}
[Fact]
public void Identical_timestamps_have_no_slope()
{
var points = Enumerable.Range(0, 20).Select(i => new DiskHistoryPoint(Now, 100, 50 - i)).ToList();
Assert.Equal((0, 0), DiskForecaster.Regression(points));
Assert.Null(DiskForecaster.Forecast(@"D:\", points, Now, new DiskForecastSettings { MinimumHistoryDays = 0, MinimumSamples = 2 }).DaysUntilFull);
}
[Fact]
public void A_shrunk_volume_starts_a_new_trend()
{
// Was 200 GB and flat; shrunk to 100 GB a day ago and filling fast since: too little history for the new size.
var history = History(14, age => age > 1 ? 150 : 20 + (10 * age)).Select(p => (Now - p.At).TotalDays > 1 ? p with { SizeGb = 200 } : p).ToList();
var forecast = DiskForecaster.Forecast(@"D:\", history, Now);
Assert.Null(forecast.DaysUntilFull);
Assert.False(forecast.EnoughHistory);
Assert.InRange(forecast.HistoryDays, 0.9, 1.1);
}
}
public class ServerPerformanceTests
{
private static readonly DateTimeOffset T0 = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void Cpu_is_one_minus_the_idle_share_of_the_interval()
{
var first = new ServerPerformanceRaw { CapturedAt = T0, ProcessorIdle100Ns = 1_000_000_000, ProcessorTimestamp100Ns = 5_000_000_000 };
// One minute = 600,000,000 × 100 ns; idle 150,000,000 of it → 75 % busy.
var second = first with { CapturedAt = T0.AddMinutes(1), ProcessorIdle100Ns = 1_150_000_000, ProcessorTimestamp100Ns = 5_600_000_000 };
Assert.Equal(75, ServerPerformanceMath.Cpu(first, second)!.Value, 6);
Assert.Null(ServerPerformanceMath.Cpu(null, second));
Assert.Null(ServerPerformanceMath.Cpu(second, first)); // counters went backwards: rebooted
}
[Fact]
public void Pages_per_second_uses_the_performance_frequency()
{
var first = new ServerPerformanceRaw { CapturedAt = T0, Pages = 1000, MemoryPerfTime = 10_000_000, MemoryPerfFrequency = 10_000_000 };
var second = first with { CapturedAt = T0.AddMinutes(1), Pages = 61_000, MemoryPerfTime = 610_000_000 };
Assert.Equal(1000, ServerPerformanceMath.PagesPerSecond(first, second)!.Value, 6);
}
[Fact]
public void Sample_has_available_percent_and_commit()
{
var raw = new ServerPerformanceRaw { CapturedAt = T0, AvailableMb = 1024, CommittedBytes = 9, CommitLimit = 10 };
var sample = ServerPerformanceMath.Sample(null, raw, new ServerOsInfo { TotalMemoryMb = 8192 });
Assert.Equal(12.5, sample.AvailablePercent!.Value, 6);
Assert.Equal(90, sample.CommitPercent!.Value, 6);
Assert.Null(sample.CpuPercent);
// Not rounded down to whole percent: 89.9 % stays under a 90 % limit only because it is under it.
var big = ServerPerformanceMath.Sample(null, raw with { CommittedBytes = 8_999_000_000, CommitLimit = 10_000_000_000 }, new ServerOsInfo());
Assert.Equal(89.99, big.CommitPercent!.Value, 6);
}
[Fact]
public void A_sample_after_a_long_gap_has_no_rates()
{
var first = new ServerPerformanceRaw
{
CapturedAt = T0, ProcessorIdle100Ns = 1_000_000_000, ProcessorTimestamp100Ns = 5_000_000_000, Pages = 1000, MemoryPerfTime = 10_000_000,
MemoryPerfFrequency = 10_000_000,
};
var minute = first with { CapturedAt = T0.AddMinutes(1), ProcessorIdle100Ns = 1_150_000_000, ProcessorTimestamp100Ns = 5_600_000_000, Pages = 61_000, MemoryPerfTime = 610_000_000 };
var hourLater = minute with { CapturedAt = T0.AddMinutes(61) };
var os = new ServerOsInfo();
Assert.Equal(75, ServerPerformanceMath.Sample(first, minute, os, TimeSpan.FromMinutes(1)).CpuPercent!.Value, 6);
var afterGap = ServerPerformanceMath.Sample(first, hourLater, os, TimeSpan.FromMinutes(1));
Assert.Null(afterGap.CpuPercent);
Assert.Null(afterGap.PagesPerSecond);
Assert.NotNull(ServerPerformanceMath.Sample(first, hourLater, os, TimeSpan.FromMinutes(30)).CpuPercent); // within three intervals
}
[Fact]
public void Sustained_needs_most_of_the_period_covered()
{
var samples = Enumerable.Range(0, 15).Select(m => new ServerPerformanceSample(T0.AddMinutes(-m), 95, null, null, null, null)).Reverse().ToList();
Assert.Equal(95, ServerPerformanceMath.SustainedAverage(samples, s => s.CpuPercent, TimeSpan.FromMinutes(15), T0, TimeSpan.FromMinutes(1)));
Assert.Null(ServerPerformanceMath.SustainedAverage(samples.TakeLast(5).ToList(), s => s.CpuPercent, TimeSpan.FromMinutes(15), T0, TimeSpan.FromMinutes(1)));
}
[Fact]
public void The_window_keeps_an_hour()
{
IReadOnlyList<ServerPerformanceSample> window = [];
for (var m = 0; m < 90; m++)
{
window = ServerPerformanceMath.Append(window, new ServerPerformanceSample(T0.AddMinutes(m), 10, null, null, null, null));
}
Assert.Equal(60, window.Count);
Assert.Equal(T0.AddMinutes(89), window[^1].At);
}
}
public class ServerEventTests
{
private static readonly DateTimeOffset Now = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero);
private static ServerEventEntry Entry(string source, long id, int level = 2, string log = "System", int minutesAgo = 1, string? message = "tekst") =>
new(log, source, id, level, Now.AddMinutes(-minutesAgo), message);
[Theory]
[InlineData("Microsoft-Windows-Kernel-Power", 41, 1, ServerEventCategory.UnexpectedShutdown)]
[InlineData("EventLog", 6008, 2, ServerEventCategory.UnexpectedShutdown)]
[InlineData("disk", 153, 3, ServerEventCategory.Disk)]
[InlineData("storvsc", 129, 3, ServerEventCategory.Disk)]
[InlineData("Ntfs", 55, 2, ServerEventCategory.Disk)]
[InlineData("Srv", 2019, 2, ServerEventCategory.Resources)]
[InlineData("Service Control Manager", 7031, 2, ServerEventCategory.ServiceFailure)]
[InlineData("Application Error", 1000, 2, ServerEventCategory.ApplicationCrash)]
[InlineData("Microsoft Dynamics AX Object Server 6.0$01", 110, 2, ServerEventCategory.DynamicsAx)]
[InlineData("Some-Driver", 5, 1, ServerEventCategory.Critical)]
[InlineData("Some-App", 5, 2, ServerEventCategory.Error)]
public void Categorize(string source, long id, int level, ServerEventCategory expected) =>
Assert.Equal(expected, ServerEventAnalysis.Categorize(Entry(source, id, level)));
[Fact]
public void Repeated_events_are_one_group_with_the_newest_sample()
{
var (groups, ignored) = ServerEventAnalysis.Group(
[
Entry("Service Control Manager", 7031, minutesAgo: 30, message: "AOS stoppede (1)"),
Entry("Service Control Manager", 7031, minutesAgo: 5, message: "AOS stoppede (2)"),
Entry("service control manager", 7031, minutesAgo: 10, message: null),
Entry("Some-App", 9, log: "Application"),
Entry("Some-App", 9, level: 3, log: "Application"), // a warning: not collected
], ServerEventAnalysis.DefaultIgnore);
Assert.Equal(0, ignored);
Assert.Equal(2, groups.Count);
var scm = groups[0];
Assert.Equal(ServerEventCategory.ServiceFailure, scm.Category);
Assert.Equal(3, scm.Count);
Assert.Equal("AOS stoppede (2)", scm.Sample);
Assert.Equal(Now.AddMinutes(-30), scm.FirstAt);
Assert.Equal(Now.AddMinutes(-5), scm.LastAt);
}
[Fact]
public void Known_noise_and_sql_server_sources_are_left_out()
{
var (groups, ignored) = ServerEventAnalysis.Group(
[
Entry("Microsoft-Windows-DistributedCOM", 10016),
Entry("Schannel", 36887),
Entry("MSSQL$PROD", 17053, log: "Application"),
Entry("MSSQLSERVER", 823, log: "Application"),
Entry("Schannel", 36871),
], ServerEventAnalysis.DefaultIgnore);
Assert.Equal(4, ignored);
Assert.Equal(36871, Assert.Single(groups).EventId);
}
[Theory]
[InlineData("Some-App:9", true)]
[InlineData("Some-App", true)]
[InlineData("Some*", true)]
[InlineData("Some*:10", false)]
[InlineData("Some-App:*", true)]
[InlineData("Other:9", false)]
public void Ignore_patterns(string pattern, bool ignored) =>
Assert.Equal(ignored, ServerEventAnalysis.IsIgnored(Entry("Some-App", 9), [pattern]));
[Fact]
public void Recent_merges_groups_of_the_last_day_and_drops_older()
{
var (earlier, _) = ServerEventAnalysis.Group([Entry("disk", 153, minutesAgo: 60 * 20), Entry("Some-App", 9, minutesAgo: 60 * 30)], []);
var (current, _) = ServerEventAnalysis.Group([Entry("disk", 153, minutesAgo: 2)], []);
var recent = ServerEventAnalysis.Recent(earlier, current, Now);
var disk = Assert.Single(recent);
Assert.Equal(2, disk.Count);
Assert.Equal(Now.AddHours(-20), disk.FirstAt);
Assert.Equal(Now.AddMinutes(-2), disk.LastAt);
}
[Fact]
public void The_24_hour_count_only_counts_the_last_24_hours()
{
// The same error every ten hours for three days, collected every five minutes as the agent does.
IReadOnlyList<ServerEventGroup> recent = [];
var start = Now.AddDays(-3);
for (var at = start; at <= Now; at = at.AddMinutes(5))
{
var entries = (at - start).TotalMinutes % 600 == 0 ? new[] { new ServerEventEntry("Application", "Some-App", 9, 2, at, "tekst") } : [];
var (groups, _) = ServerEventAnalysis.Group(entries, []);
recent = ServerEventAnalysis.Recent(recent, groups, at);
}
var group = Assert.Single(recent);
Assert.Equal(3, group.Count); // not the 8 seen since the first one
Assert.True(Now - group.FirstAt <= ServerEventAnalysis.RecentWindow);
Assert.InRange(group.Buckets.Count, 1, 25);
}
[Fact]
public void Groups_saved_without_hourly_counts_are_still_merged()
{
var old = new ServerEventGroup("System", "disk", 153, 2, ServerEventCategory.Disk, 4, Now.AddHours(-5), Now.AddHours(-3), "tekst");
var (current, _) = ServerEventAnalysis.Group([Entry("disk", 153, minutesAgo: 2)], []);
var disk = Assert.Single(ServerEventAnalysis.Recent([old], current, Now));
Assert.Equal(5, disk.Count);
Assert.Equal(Now.AddHours(-5), disk.FirstAt);
Assert.Empty(ServerEventAnalysis.Recent([old], [], Now.AddHours(22)));
}
[Fact]
public void Samples_are_cut()
{
var (groups, _) = ServerEventAnalysis.Group([Entry("Some-App", 9, message: new string('x', 2000))], []);
Assert.Equal(500, groups[0].Sample!.Length);
}
}
public class ServerConfigurationTests
{
[Theory]
[InlineData("aos01", null)]
[InlineData("aos01.firma.local", null)]
[InlineData("10.0.0.5", null)]
[InlineData("fe80::1", null)]
[InlineData(@"sql01\PROD", "uden instansnavn")]
[InlineData("aos01:2712", "uden instansnavn")]
[InlineData("", "Angiv")]
public void Hosts(string host, string? error)
{
var result = ServerHost.Validate(host);
if (error is null)
{
Assert.Null(result);
}
else
{
Assert.Contains(error, result, StringComparison.Ordinal);
}
}
[Theory]
[InlineData(@"tcp:SQL01.firma.local\PROD,1433", "sql01.firma.local", false)]
[InlineData("sql02,1500", "sql02", false)]
[InlineData(@".\SQLEXPRESS", "agent01", false)]
[InlineData("(local)", "agent01", false)]
[InlineData("localhost", "agent01", false)]
[InlineData("(localdb)\\MSSQLLocalDB", null, true)]
[InlineData("kunde.database.windows.net", null, true)]
public void The_computer_behind_a_sql_data_source(string dataSource, string? host, bool skipped)
{
var (found, reason) = ServerHost.FromDataSource(dataSource, "AGENT01");
Assert.Equal(host, found);
Assert.Equal(skipped, reason is not null);
}
[Fact]
public void Lists_of_service_names()
{
Assert.Equal(["AOS60$01", "W3SVC"], ServerHost.ParseList("AOS60$01, W3SVC\nw3svc;"));
Assert.Null(ServerHost.ValidateServiceNames(["AOS60$01", "MSSQL$PROD"]));
Assert.NotNull(ServerHost.ValidateServiceNames([@"bad\name"]));
}
[Theory]
[InlineData(@"NT SERVICE\SQLInsight Agent", "aos01.firma.local", @"FIRMA\AGENT01$")]
[InlineData("LocalSystem", "aos01", @"FIRMA\AGENT01$")]
[InlineData(@"NT SERVICE\SQLInsight Agent", "agent01.firma.local", @"NT SERVICE\SQLInsight Agent")]
[InlineData(@"FIRMA\gmsa-sqlinsight$", "aos01", @"FIRMA\gmsa-sqlinsight$")]
public void The_account_the_agent_arrives_as(string service, string host, string expected) =>
Assert.Equal(expected, ServerGrantScript.AccountFor(service, host, "AGENT01", "FIRMA"));
[Theory]
[InlineData(0u, false)]
[InlineData(TaskResults.Running, false)]
[InlineData(TaskResults.HasNotRun, false)]
[InlineData(TaskResults.AlreadyRunning, false)]
[InlineData(1u, true)]
[InlineData(0x80070005u, true)]
[InlineData(TaskResults.TerminatedByUser, true)]
public void Task_results(uint result, bool failure)
{
Assert.Equal(failure, TaskResults.IsFailure(result));
Assert.False(string.IsNullOrWhiteSpace(TaskResults.Describe(result)));
}
}