Wait type drill-down with active session sampling
A new light collector samples sys.dm_exec_requests every 15 seconds:
waiting user sessions (not the agent's own) by wait type, database and
statement (same key as top SQL), with blocking. Sampling-only cycles
reuse the last probe and skip rule evaluation. Stored in
insight.ActiveWaitSamples (migration 0010) for RawDays.
Wait types on the instance page and in the analysis link to
/instanser/{navn}/ventetyper/{type}: Danish explanation from a new
knowledge base (shared with the SQLI-WAIT rules, with Dynamics AX notes),
trend over time, the statements that waited with share and estimated
wait, a link to the full text (/instanser/{navn}/sql) and, for lock
waits, to blocking in the same window. SQL Server 2005 compatible.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4ba7e5195e
commit
20671bfb06
18 changed files with 950 additions and 24 deletions
|
|
@ -55,3 +55,22 @@ Agenten læser `sys.dm_exec_query_stats` hvert 5. minut (collector `query-stats`
|
||||||
Rettigheden er `VIEW SERVER STATE` fra `grant-least-privilege.sql`. SQL Server 2000 har ikke DMV'en, så her er der ingen forespørgselsstatistik.
|
Rettigheden er `VIEW SERVER STATE` fra `grant-least-privilege.sql`. SQL Server 2000 har ikke DMV'en, så her er der ingen forespørgselsstatistik.
|
||||||
|
|
||||||
**Forespørgselstekst er kundedata.** Den gemmes kun i kundens eget repository, vises kun i webbrugerfladen og i PDF'en hos kunden, og sendes aldrig til SQLInsight Hub. Se [databehandling.md](databehandling.md).
|
**Forespørgselstekst er kundedata.** Den gemmes kun i kundens eget repository, vises kun i webbrugerfladen og i PDF'en hos kunden, og sendes aldrig til SQLInsight Hub. Se [databehandling.md](databehandling.md).
|
||||||
|
|
||||||
|
## Ventetyper: hvad venter på hvad
|
||||||
|
|
||||||
|
Klik på en ventetype på instanssiden eller i analysen for at åbne `/instanser/{navn}/ventetyper/{ventetype}?fra=&til=`. Siden viser:
|
||||||
|
|
||||||
|
- **Hvad betyder det?** En dansk forklaring: hvad ventetypen betyder, hvornår den er normal, typiske årsager og hvad man kan gøre, med noter for Dynamics AX hvor det er relevant (fx MAXDOP 1 ved CXPACKET). Teksterne er de samme som i ventetidsreglerne (SQLI-WAIT-*). Ventetyper uden dansk forklaring får en generel tekst med kategorien og et link til Microsofts liste.
|
||||||
|
- **Udvikling**: ventetid pr. minut over tid (hvert 5. minut inden for `RawDays`, ellers timeværdier).
|
||||||
|
- **Hvilke sætninger venter**: de sætninger, sessionerne ventede i, med antal stikprøver, andel, anslået ventetid (stikprøver × 15 s), gennemsnitlig ventet tid, database, et uddrag af teksten og et link til hele teksten.
|
||||||
|
- For låseventetider (LCK_M_*) et link til blocking i samme tidsrum.
|
||||||
|
|
||||||
|
### Stikprøver hvert 15. sekund
|
||||||
|
|
||||||
|
Ventetidsstatistikken (`sys.dm_os_wait_stats`) siger hvor meget der ventes, men ikke på hvad. Derfor tager agenten hvert 15. sekund en stikprøve af `sys.dm_exec_requests` (som Oracles ASH): for hver brugersession, der venter, gemmes ventetype, ventet tid, database, sætningens nøgle (den samme som for tungeste forespørgsler, så teksten deles) og om den er blokeret. Agentens egne sessioner (program "SQLInsight Pro…") og systemsessioner tages ikke med, og det samme gælder harmløse baggrundsventetider.
|
||||||
|
|
||||||
|
- Stikprøverne summeres pr. ventetype, database og sætning og gemmes i `insight.ActiveWaitSamples` i `RawDays` (standard 14 dage).
|
||||||
|
- En stikprøve er én let forespørgsel; agenten genkender instansen fra seneste fulde gennemløb og evaluerer ikke regler for stikprøverne alene.
|
||||||
|
- Ventetider, der varer under 15 sekunder i alt, kan falde mellem to stikprøver. Tallene er et billede af, hvor ventetiden ligger, ikke en præcis måling.
|
||||||
|
- Virker fra SQL Server 2005 (her er nøglen batch og position, fordi `query_hash` først kom i 2008). Intervallet kan ændres med `SQLInsight:CollectorIntervals:wait-sampling`.
|
||||||
|
- Sætningernes tekst er kundens data og sendes aldrig til SQLInsight Hub.
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ public sealed partial class InstanceMonitor(
|
||||||
private readonly Dictionary<string, object> _previous = [];
|
private readonly Dictionary<string, object> _previous = [];
|
||||||
private readonly Dictionary<string, DateTimeOffset> _nextDue = [];
|
private readonly Dictionary<string, DateTimeOffset> _nextDue = [];
|
||||||
private DateTimeOffset _nextBaseline = DateTimeOffset.MinValue;
|
private DateTimeOffset _nextBaseline = DateTimeOffset.MinValue;
|
||||||
|
private (InstanceInfo Info, int InstanceId)? _last;
|
||||||
|
|
||||||
/// <summary>Metrics a baseline is kept for. Must match what the repository stores (see <see cref="Metrics"/>).</summary>
|
/// <summary>Metrics a baseline is kept for. Must match what the repository stores (see <see cref="Metrics"/>).</summary>
|
||||||
public static readonly string[] BaselineMetrics = [Metrics.CpuSqlServer, Metrics.BatchRequestsPerSecond, Metrics.PageLifeExpectancy];
|
public static readonly string[] BaselineMetrics = [Metrics.CpuSqlServer, Metrics.BatchRequestsPerSecond, Metrics.PageLifeExpectancy];
|
||||||
|
|
@ -55,6 +56,13 @@ public sealed partial class InstanceMonitor(
|
||||||
_nextDue[collector.Name] = now + IntervalOf(collector);
|
_nextDue[collector.Name] = now + IntervalOf(collector);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only frequent samples are due: store them without probing the instance again or evaluating the rules.
|
||||||
|
if (_last is { } last && _wasReachable == true && due.All(c => c.IsSampling))
|
||||||
|
{
|
||||||
|
await SampleAsync(due, last, now, cancellationToken).ConfigureAwait(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
OpenedConnection opened;
|
OpenedConnection opened;
|
||||||
InstanceInfo info;
|
InstanceInfo info;
|
||||||
int instanceId;
|
int instanceId;
|
||||||
|
|
@ -85,6 +93,7 @@ public sealed partial class InstanceMonitor(
|
||||||
LogLegacyFallback(logger, instance.Name);
|
LogLegacyFallback(logger, instance.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_last = (info, instanceId);
|
||||||
var capabilities = InstanceCapabilities.From(info);
|
var capabilities = InstanceCapabilities.From(info);
|
||||||
foreach (var collector in due)
|
foreach (var collector in due)
|
||||||
{
|
{
|
||||||
|
|
@ -121,6 +130,36 @@ public sealed partial class InstanceMonitor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs sampling collectors with what the last full cycle learned about the instance. A failed connection is left
|
||||||
|
/// to the next full cycle to report.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SampleAsync(List<ICollector> due, (InstanceInfo Info, int InstanceId) last, DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
OpenedConnection opened;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
opened = await connectionFactory.OpenAsync(instance.ToTarget(), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
LogSampleConnectFailed(logger, instance.Name, ex.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (opened.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var capabilities = InstanceCapabilities.From(last.Info);
|
||||||
|
foreach (var collector in due)
|
||||||
|
{
|
||||||
|
await CollectAsync(collector, opened, last.Info, capabilities, last.InstanceId, now, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Debug, Message = "{Instance}: sampling skipped, could not connect: {Error}")]
|
||||||
|
private static partial void LogSampleConnectFailed(ILogger logger, string instance, string error);
|
||||||
|
|
||||||
private async Task CollectAsync(
|
private async Task CollectAsync(
|
||||||
ICollector collector, OpenedConnection opened, InstanceInfo info, InstanceCapabilities capabilities, int instanceId, DateTimeOffset now, CancellationToken cancellationToken)
|
ICollector collector, OpenedConnection opened, InstanceInfo info, InstanceCapabilities capabilities, int instanceId, DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ public static class BuiltInCollectors
|
||||||
new WaitStatsCollector(),
|
new WaitStatsCollector(),
|
||||||
new FileIoCollector(),
|
new FileIoCollector(),
|
||||||
new QueryStatsCollector(),
|
new QueryStatsCollector(),
|
||||||
|
new WaitSamplingCollector(),
|
||||||
new PerformanceCounterCollector(),
|
new PerformanceCounterCollector(),
|
||||||
new CpuCollector(),
|
new CpuCollector(),
|
||||||
new BlockingCollector(),
|
new BlockingCollector(),
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@ public interface ICollector
|
||||||
/// <summary>What the instance must support. If anything is missing, the collector does not run.</summary>
|
/// <summary>What the instance must support. If anything is missing, the collector does not run.</summary>
|
||||||
IReadOnlyCollection<Capability> RequiredCapabilities { get; }
|
IReadOnlyCollection<Capability> RequiredCapabilities { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A frequent, light sample (active session sampling): stored, but rules are not evaluated for it alone, so it can
|
||||||
|
/// run every few seconds without a full monitoring cycle.
|
||||||
|
/// </summary>
|
||||||
|
bool IsSampling => false;
|
||||||
|
|
||||||
Task<object> CollectAsync(CollectorContext context);
|
Task<object> CollectAsync(CollectorContext context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,6 +47,7 @@ public abstract class Collector<T> : ICollector
|
||||||
public Type DataType => typeof(T);
|
public Type DataType => typeof(T);
|
||||||
public abstract TimeSpan DefaultInterval { get; }
|
public abstract TimeSpan DefaultInterval { get; }
|
||||||
public virtual IReadOnlyCollection<Capability> RequiredCapabilities => [];
|
public virtual IReadOnlyCollection<Capability> RequiredCapabilities => [];
|
||||||
|
public virtual bool IsSampling => false;
|
||||||
|
|
||||||
async Task<object> ICollector.CollectAsync(CollectorContext context) =>
|
async Task<object> ICollector.CollectAsync(CollectorContext context) =>
|
||||||
await CollectAsync(context, context.Previous as T).ConfigureAwait(false);
|
await CollectAsync(context, context.Previous as T).ConfigureAwait(false);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Collectors.Performance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Active session sampling: every 15 seconds, what each waiting user request waits on, in which database and for
|
||||||
|
/// which statement (the same key as top SQL). Sums of these samples show which statements a wait type comes from.
|
||||||
|
/// The agent's own sessions and system sessions are left out. One light query on sys.dm_exec_requests; SQL Server 2005+.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WaitSamplingCollector : Collector<WaitSampleSnapshot>
|
||||||
|
{
|
||||||
|
private const string From = """
|
||||||
|
FROM sys.dm_exec_requests r
|
||||||
|
JOIN sys.dm_exec_sessions s ON s.session_id = r.session_id
|
||||||
|
OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) st
|
||||||
|
WHERE s.is_user_process = 1 AND r.session_id <> @@SPID AND r.wait_type IS NOT NULL
|
||||||
|
AND (s.program_name IS NULL OR s.program_name NOT LIKE N'SQLInsight Pro%')
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string Columns = """
|
||||||
|
r.session_id, r.wait_type, r.wait_time, DB_NAME(r.database_id), r.blocking_session_id,
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string Text = """
|
||||||
|
CAST(SUBSTRING(st.text, r.statement_start_offset / 2 + 1,
|
||||||
|
CASE WHEN r.statement_end_offset = -1 THEN 4000 ELSE (r.statement_end_offset - r.statement_start_offset) / 2 + 1 END) AS nvarchar(4000))
|
||||||
|
""";
|
||||||
|
|
||||||
|
/// <summary>The statement key as top SQL makes it on 2008+: query_hash.</summary>
|
||||||
|
internal const string Sql2008 = "SELECT " + Columns + """
|
||||||
|
CASE WHEN r.query_hash IS NULL OR r.query_hash = 0x0000000000000000 THEN NULL ELSE CONVERT(varchar(20), r.query_hash, 1) END,
|
||||||
|
""" + "\n" + Text + "\n" + From;
|
||||||
|
|
||||||
|
/// <summary>SQL Server 2005 has no query_hash: the batch (sql_handle) and the statement's offsets, as top SQL.</summary>
|
||||||
|
internal const string Sql2005 = "SELECT " + Columns + """
|
||||||
|
CASE WHEN r.sql_handle IS NULL THEN NULL
|
||||||
|
ELSE sys.fn_varbintohexstr(r.sql_handle) + ':' + CAST(r.statement_start_offset AS varchar(12)) + ':' + CAST(r.statement_end_offset AS varchar(12)) END,
|
||||||
|
""" + "\n" + Text + "\n" + From;
|
||||||
|
|
||||||
|
public override string Name => "wait-sampling";
|
||||||
|
public override TimeSpan DefaultInterval => WaitSampling.Interval;
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
|
||||||
|
public override bool IsSampling => true;
|
||||||
|
|
||||||
|
protected override async Task<WaitSampleSnapshot> CollectAsync(CollectorContext context, WaitSampleSnapshot? previous)
|
||||||
|
{
|
||||||
|
var sql = context.Instance.Version.Major >= 10 ? Sql2008 : Sql2005;
|
||||||
|
var requests = await Db.QueryAsync(context.Connection, sql, r => new WaitingRequest(
|
||||||
|
r.Int(0), r.String(1), r.Long(2), r.NullableString(3), r.NullableString(5), r.NullableString(6), r.NullableInt(4) ?? 0),
|
||||||
|
context.CancellationToken).ConfigureAwait(false);
|
||||||
|
return WaitSampling.Aggregate(requests, context.Now);
|
||||||
|
}
|
||||||
|
}
|
||||||
259
src/SQLInsight.Core/Data/WaitKnowledge.cs
Normal file
259
src/SQLInsight.Core/Data/WaitKnowledge.cs
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
namespace SQLInsight.Core.Data;
|
||||||
|
|
||||||
|
/// <summary>What a wait type means, in Danish, for the customer. <see cref="Known"/> is false for the generic text.</summary>
|
||||||
|
/// <param name="Family">The wait types it covers, e.g. "LCK_M_*".</param>
|
||||||
|
/// <param name="Normal">When the wait is normal and nothing to act on.</param>
|
||||||
|
/// <param name="DynamicsAx">Notes for Dynamics AX / 365 F&O installations; null when nothing specific.</param>
|
||||||
|
public sealed record WaitExplanation(
|
||||||
|
string Family,
|
||||||
|
string Title,
|
||||||
|
string Meaning,
|
||||||
|
string Normal,
|
||||||
|
string Causes,
|
||||||
|
string Actions,
|
||||||
|
string? DynamicsAx = null,
|
||||||
|
bool Known = true)
|
||||||
|
{
|
||||||
|
/// <summary>Microsoft's list of all wait types.</summary>
|
||||||
|
public static readonly Uri MicrosoftDocs = new("https://learn.microsoft.com/sql/relational-databases/system-dynamic-management-views/sys-dm-os-wait-stats-transact-sql");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Danish knowledge base of wait types: what each means, when it is normal, typical causes and what to do. The wait
|
||||||
|
/// rules (SQLI-WAIT-*) use the same texts, so a finding and the wait type page say the same.
|
||||||
|
/// </summary>
|
||||||
|
public static class WaitKnowledge
|
||||||
|
{
|
||||||
|
public static readonly WaitExplanation Locks = new(
|
||||||
|
"LCK_M_*",
|
||||||
|
"Venter på en lås",
|
||||||
|
"En stor del af ventetiden er LCK_M_*: sessioner venter på låse, som andre sessioner holder. Det skyldes typisk "
|
||||||
|
+ "lange transaktioner, manglende indexes (så opdateringer låser flere rækker end nødvendigt) eller "
|
||||||
|
+ "læsninger, der blokeres af skrivninger.",
|
||||||
|
"Korte låseventetider (millisekunder) er normalt, når mange arbejder på de samme data. Det bliver et problem, når enkelte ventetider varer sekunder, eller når mange sessioner venter på den samme.",
|
||||||
|
"Lange transaktioner, en transaktion der står åben (fx en bruger midt i en dialog), opdateringer uden passende index, "
|
||||||
|
+ "eskalering til tabellås ved store opdateringer, og læsere der venter på skrivere (READ COMMITTED uden row versioning). "
|
||||||
|
+ "Bogstaverne efter LCK_M_ angiver låsetypen: S (læs), U (opdatér), X (skriv), IS/IX/IU (hensigt), SCH_S/SCH_M (skema), RS/RIn/RX (range, ved SERIALIZABLE).",
|
||||||
|
"Se blocking-fundene for, hvem der blokerer. Hold transaktioner korte, tilføj indexes til WHERE-betingelserne i "
|
||||||
|
+ "opdateringer, og overvej READ_COMMITTED_SNAPSHOT, så læsere ikke blokeres af skrivere (test først: det "
|
||||||
|
+ "belaster tempdb og ændrer adfærd for kode, der er afhængig af låse).",
|
||||||
|
"AX bruger optimistisk samtidighed for de fleste tabeller, men nummerserier (NUMBERSEQUENCETABLE), lagerposter og "
|
||||||
|
+ "batchjob, der opdaterer mange rækker i én transaktion, giver typisk LCK_M_U/LCK_M_X. AX 2009/2012 kører som "
|
||||||
|
+ "standard med READ_COMMITTED_SNAPSHOT slået til; er det slået fra, giver læsninger LCK_M_S.");
|
||||||
|
|
||||||
|
public static readonly WaitExplanation BufferIo = new(
|
||||||
|
"PAGEIOLATCH_*",
|
||||||
|
"Venter på at læse data fra disk",
|
||||||
|
"PAGEIOLATCH-ventetider betyder, at sider skal læses fra disk, fordi de ikke er i buffer pool. Årsagen er "
|
||||||
|
+ "enten for lidt hukommelse, forespørgsler der læser langt mere end nødvendigt (scans, manglende indexes), "
|
||||||
|
+ "eller langsom storage. Høj ventetid er et symptom; årsagen skal findes.",
|
||||||
|
"Lidt PAGEIOLATCH_SH er normalt, især efter genstart og når der læses data, der sjældent bruges (rapporter, natlige job).",
|
||||||
|
"Forespørgsler der scanner store tabeller, for lidt hukommelse til de data, der bruges ofte (lav Page Life Expectancy), "
|
||||||
|
+ "langsom eller overbelastet storage. _SH er læsning, _EX/_UP er sider, der skal ændres.",
|
||||||
|
"Tjek først I/O-latens pr. fil (hvis den er lav, er problemet mængden af læsninger, ikke disken). Find de "
|
||||||
|
+ "forespørgsler, der læser mest, i Query Store eller sys.dm_exec_query_stats, og se på deres planer og indexes. "
|
||||||
|
+ "Tjek derefter, om max server memory er sat fornuftigt.",
|
||||||
|
"I AX skyldes det ofte manglende indexes på kundetilpassede tabeller eller forespørgsler uden DATAAREAID (og PARTITION fra AX 2012) først i indexet.");
|
||||||
|
|
||||||
|
public static readonly WaitExplanation Parallelism = new(
|
||||||
|
"CXPACKET, CXCONSUMER, CXSYNC_*",
|
||||||
|
"Parallelle forespørgsler venter på hinanden",
|
||||||
|
"CXPACKET er i sig selv normalt for parallelle planer. Når det dominerer, betyder det typisk mange små "
|
||||||
|
+ "forespørgsler, der unødigt går parallelt, eller skæv fordeling af rækker mellem trådene.",
|
||||||
|
"CXCONSUMER (fra 2016 SP2/2017 CU3) er forbrugersiden og næsten altid harmløs. CXPACKET og CXSYNC_PORT/CXSYNC_CONSUMER i moderat mængde er normalt på en server med rapporter og store forespørgsler.",
|
||||||
|
"Lav cost threshold for parallelism (standard 5), MAXDOP 0 på en server med mange kerner, manglende indexes så "
|
||||||
|
+ "forespørgsler scanner, og forældede statistikker der giver skæv fordeling mellem tråde.",
|
||||||
|
"Tjek cost threshold for parallelism og MAXDOP (se konfigurationsfund). Find de parallelle forespørgsler med "
|
||||||
|
+ "højest CPU i Query Store; ofte fjerner et bedre index behovet for parallelisme.",
|
||||||
|
"Microsoft anbefaler MAXDOP 1 for Dynamics AX 2009/2012 (og AX 4.0), fordi AX' mange korte OLTP-forespørgsler "
|
||||||
|
+ "ikke har gavn af parallelisme. Ses CXPACKET på en AX-database, er MAXDOP sandsynligvis ikke sat til 1.");
|
||||||
|
|
||||||
|
public static readonly WaitExplanation PageLatch = new(
|
||||||
|
"PAGELATCH_*",
|
||||||
|
"Konkurrence om sider i hukommelsen",
|
||||||
|
"PAGELATCH-ventetider er konkurrence om sider, der allerede er i hukommelsen. De to klassiske årsager er "
|
||||||
|
+ "allokeringssider i tempdb (mange midlertidige objekter) og 'last page insert' på et index med stigende nøgle.",
|
||||||
|
"Korte PAGELATCH-ventetider er normalt under travl indsættelse.",
|
||||||
|
"Mange samtidige oprettelser af temp-tabeller med for få tempdb-datafiler (PFS/GAM/SGAM-sider), og mange "
|
||||||
|
+ "samtidige indsættelser i den sidste side af et index med stigende nøgle (identity, dato, RECID).",
|
||||||
|
"Tjek wait_resource i sys.dm_exec_requests under belastning: 2:x:y betyder tempdb (tilføj lige store datafiler, "
|
||||||
|
+ "se tempdb-fund). Ved en brugertabel: overvej OPTIMIZE_FOR_SEQUENTIAL_KEY (2019+) eller en anden nøgle.",
|
||||||
|
"AX bruger mange temp-tabeller (TempDB-tabeller i AX 2012): tempdb bør have flere lige store datafiler.");
|
||||||
|
|
||||||
|
public static readonly WaitExplanation ThreadPool = new(
|
||||||
|
"THREADPOOL",
|
||||||
|
"SQL Server løber tør for worker threads",
|
||||||
|
"THREADPOOL-ventetider betyder, at nye opgaver ikke kunne få en worker thread. Imens hænger nye forbindelser og "
|
||||||
|
+ "forespørgsler, og serveren virker død udefra. Årsagen er næsten altid massiv blocking (mange sessioner, der "
|
||||||
|
+ "venter på den samme lås) eller mange samtidige parallelle forespørgsler.",
|
||||||
|
"Aldrig. Selv korte THREADPOOL-ventetider betyder, at serveren i et øjeblik ikke kunne tage imod mere arbejde.",
|
||||||
|
"En head blocker, som hundredvis af sessioner venter på, mange parallelle forespørgsler på én gang, eller for få CPU'er til belastningen.",
|
||||||
|
"Find head blocker (se blocking-fund) og løs den. Brug Dedicated Admin Connection (ADMIN:), hvis almindelige "
|
||||||
|
+ "forbindelser hænger. Hæv ikke 'max worker threads' som løsning; det flytter kun problemet.");
|
||||||
|
|
||||||
|
public static readonly WaitExplanation WriteLog = new(
|
||||||
|
"WRITELOG",
|
||||||
|
"Commits venter på at skrive til transaktionsloggen",
|
||||||
|
"Hver commit venter, til loggen er skrevet til disk. Gennemsnitligt over 5 ms pr. WRITELOG-wait gør alle "
|
||||||
|
+ "skrivende transaktioner langsommere og begrænser, hvor mange commits serveren kan nå pr. sekund.",
|
||||||
|
"WRITELOG findes på alle servere, der skriver. Det er normalt, så længe den gennemsnitlige ventetid pr. wait er under 1–2 ms (lokal SSD) eller 5 ms.",
|
||||||
|
"Langsom storage under logfilen, logfilen på samme disk som datafilerne, mange små transaktioner (commit pr. række i en løkke), eller synkron replikering.",
|
||||||
|
"Tjek skrivelatens på logfilerne (se I/O-fund). Placér loggen på hurtig storage med lav latens, adskilt fra datafiler. "
|
||||||
|
+ "Undgå mange små transaktioner i løkker; saml dem i batches. Overvej delayed durability kun, hvis datatab ved nedbrud er acceptabelt.",
|
||||||
|
"AX-batchjob, der poster mange enkelttransaktioner (fx lagerposteringer), giver mange commits; hurtig log-storage betyder meget.");
|
||||||
|
|
||||||
|
public static readonly WaitExplanation SchedulerYield = new(
|
||||||
|
"SOS_SCHEDULER_YIELD",
|
||||||
|
"Forespørgsler har brugt deres tid på CPU'en og venter på at komme til igen",
|
||||||
|
"En task har brugt sit tidskvantum (4 ms) på CPU'en og har frivilligt givet plads til andre. Den venter så på at komme til igen. "
|
||||||
|
+ "Meget af det betyder, at CPU'en er den begrænsende ressource.",
|
||||||
|
"Altid til stede på en server, der arbejder. Det er kun et problem, når CPU-forbruget samtidig er højt, eller signal wait er over ca. 25 %.",
|
||||||
|
"CPU-tunge forespørgsler: scans af data i hukommelsen, manglende indexes, implicitte konverteringer, skalare funktioner, for mange kompileringer.",
|
||||||
|
"Find forespørgslerne med højest CPU-forbrug i Query Store eller sys.dm_exec_query_stats og optimér dem "
|
||||||
|
+ "(manglende indexes, implicitte konverteringer, skalare funktioner). Tjek MAXDOP og cost threshold for parallelism. "
|
||||||
|
+ "Mere CPU er først svaret, når forespørgslerne er effektive.");
|
||||||
|
|
||||||
|
private static readonly WaitExplanation[] Families =
|
||||||
|
[
|
||||||
|
Locks, BufferIo, Parallelism, PageLatch, ThreadPool, WriteLog, SchedulerYield,
|
||||||
|
new("LOGBUFFER", "Venter på plads i logbufferen",
|
||||||
|
"En session venter på plads i logbufferen i hukommelsen, fordi den ikke bliver skrevet til disk hurtigt nok.",
|
||||||
|
"Sjælden og kortvarig på en sund server.",
|
||||||
|
"Langsom log-storage, eller meget store mængder log på kort tid (indexvedligehold, store opdateringer).",
|
||||||
|
"Behandles som WRITELOG: tjek skrivelatens på logfilerne og storage under dem."),
|
||||||
|
new("ASYNC_NETWORK_IO", "SQL Server venter på, at klienten henter data",
|
||||||
|
"SQL Server har resultatet klar, men klienten (applikationen) læser det ikke hurtigt nok. Ventetiden er i klienten eller netværket, ikke i SQL Server.",
|
||||||
|
"Normalt ved store resultatsæt over netværk og ved klienter, der behandler række for række.",
|
||||||
|
"Applikationer, der henter mange rækker og behandler dem én ad gangen, rapporter der henter hele tabeller, et langsomt netværk eller en overbelastet applikationsserver.",
|
||||||
|
"Find hvilken applikation og forespørgsel det er (forespørgslerne nedenfor). Hent kun de rækker og kolonner, der bruges, og behandl resultatet hurtigt. "
|
||||||
|
+ "Tjek CPU og netværk på applikationsserveren. At give SQL Server mere hukommelse eller CPU hjælper ikke.",
|
||||||
|
"I AX er det typisk AOS'en: en AOS-server med høj CPU, eller X++-kode der løber igennem mange poster med while select og laver arbejde pr. post."),
|
||||||
|
new("RESOURCE_SEMAPHORE", "Forespørgsler venter på hukommelse til at køre",
|
||||||
|
"En forespørgsel skal bruge hukommelse til sortering eller hash (memory grant) og venter, fordi der ikke er nok ledig. Den er ikke begyndt at køre endnu.",
|
||||||
|
"Aldrig i længere tid. Ses det, er Memory Grants Pending større end 0.",
|
||||||
|
"Forespørgsler med store memory grants (store sorteringer og hash joins, ofte pga. forkerte estimater), for lidt hukommelse, eller mange samtidige rapporter.",
|
||||||
|
"Find forespørgslerne med de største memory grants (sys.dm_exec_query_memory_grants), opdatér statistikker og tilføj indexes, så de ikke skal sortere så meget. "
|
||||||
|
+ "Tjek max server memory. Resource Governor kan begrænse grants for rapporter."),
|
||||||
|
new("RESOURCE_SEMAPHORE_QUERY_COMPILE", "Kompileringer venter på hukommelse",
|
||||||
|
"For mange store forespørgsler kompileres samtidig, og SQL Server begrænser hvor mange der må bruge meget hukommelse til kompilering.",
|
||||||
|
"Sjælden. Ses typisk ved mange ad hoc-forespørgsler, der ikke genbruger planer.",
|
||||||
|
"Mange forskellige ad hoc-forespørgsler (literaler i stedet for parametre), meget komplekse forespørgsler, eller plan cache, der tømmes ofte.",
|
||||||
|
"Tjek SQL Compilations/sec mod Batch Requests/sec. Brug parametre, overvej 'optimize for ad hoc workloads' og forced parameterization for en database med mange ens ad hoc-forespørgsler.",
|
||||||
|
"AX sender forespørgsler med literaler, når 'Use literals' er valgt på forespørgslen eller tabellen; det giver mange kompileringer."),
|
||||||
|
new("CMEMTHREAD", "Konkurrence om en delt hukommelsesstruktur",
|
||||||
|
"Tråde venter på adgang til et hukommelsesobjekt, der deles af mange. Ses på servere med mange kerner og mange samtidige kompileringer.",
|
||||||
|
"Kortvarigt og i små mængder.",
|
||||||
|
"Mange ad hoc-forespørgsler, der skal i plan cache samtidig, særligt på NUMA-servere med mange kerner.",
|
||||||
|
"Reducér antallet af kompileringer (parametre, 'optimize for ad hoc workloads'). Trace flag 8048 (2014 og ældre) partitionerer objekterne pr. CPU; fra 2016 sker det automatisk."),
|
||||||
|
new("IO_COMPLETION", "Venter på I/O, der ikke er læsning af datasider",
|
||||||
|
"Venter på anden I/O end datasider: sort og hash, der skriver til tempdb, læsning af logfilen ved fx rollback, og ved oprettelse af filer.",
|
||||||
|
"Ses ved store sorteringer, backup/restore og ved databasestart.",
|
||||||
|
"Store sorteringer og hash joins, der spildes til tempdb, langsom tempdb-storage, langsom logfil.",
|
||||||
|
"Find forespørgsler med spill til tempdb (Sort/Hash Warnings i planen), og tjek latens på tempdb og logfiler."),
|
||||||
|
new("ASYNC_IO_COMPLETION", "Venter på asynkron I/O, typisk backup eller filvækst",
|
||||||
|
"Venter på asynkron I/O: backup-læsning, oprettelse eller udvidelse af filer uden Instant File Initialization, og databasesnapshots.",
|
||||||
|
"Normalt under backup, restore og filvækst.",
|
||||||
|
"Backup, filvækst (særligt logfiler, som altid skal nulstilles), manglende Instant File Initialization.",
|
||||||
|
"Hvis det falder sammen med backup, er det forventet. Ellers: slå Instant File Initialization til og sæt autogrowth til faste, passende størrelser."),
|
||||||
|
new("BACKUPIO, BACKUPBUFFER", "Backup venter på disk eller bånd",
|
||||||
|
"Backup eller restore venter på at læse eller skrive data. BACKUPIO er ventetid på backupmediet, BACKUPBUFFER på buffere.",
|
||||||
|
"Normalt under backup og restore.",
|
||||||
|
"Langsomt backupmål (netværksdrev, bånd), backup af mange databaser på én gang, eller backup-software der læser langsomt.",
|
||||||
|
"Sammenhold med backuptiderne. Backup til hurtigere mål, kompression, flere filer (striping) eller spredning af backups over tid."),
|
||||||
|
new("HADR_SYNC_COMMIT", "Commits venter på den synkrone replika",
|
||||||
|
"Med synkron Availability Group venter hver commit på, at den sekundære replika har skrevet loggen.",
|
||||||
|
"Altid lidt til stede med synkron commit; et par millisekunder pr. commit er normalt.",
|
||||||
|
"Netværkslatens til den sekundære replika, langsom log-storage på den sekundære, eller en sekundær med redo-kø.",
|
||||||
|
"Tjek netværket og log-storage på den sekundære replika. Overvej asynkron commit for replikaer, der ikke skal bruges til automatisk failover."),
|
||||||
|
new("OLEDB", "Venter på en linked server eller en DMV-funktion",
|
||||||
|
"SQL Server venter på svar fra en OLE DB-udbyder: oftest en linked server-forespørgsel, men også visse DMV-funktioner og DBCC.",
|
||||||
|
"Normalt ved brug af linked servers og under DBCC CHECKDB.",
|
||||||
|
"Langsomme forespørgsler mod linked servers, eller forespørgsler, der henter hele fjerntabeller over netværket.",
|
||||||
|
"Find forespørgslerne nedenfor. Lad den fjerne server filtrere (OPENQUERY), og hent kun de rækker, der skal bruges."),
|
||||||
|
new("PREEMPTIVE_*", "SQL Server venter på Windows eller en ekstern komponent",
|
||||||
|
"Tråden har overladt kontrollen til Windows (filsystem, netværk, sikkerhed, COM) eller en ekstern komponent, og SQL Server kan ikke se ind i den ventetid.",
|
||||||
|
"Mange PREEMPTIVE_* er normale baggrundsopgaver. Navnet efter PREEMPTIVE_OS_ siger hvilket kald, fx AUTHENTICATIONOPS (AD-login), WRITEFILEGATHER (filvækst).",
|
||||||
|
"Langsom domænecontroller ved Windows-login, filvækst uden Instant File Initialization, linked servers, CLR og extended stored procedures.",
|
||||||
|
"Se navnet: PREEMPTIVE_OS_AUTHENTICATIONOPS → tjek forbindelsen til domænecontrolleren; PREEMPTIVE_OS_WRITEFILEGATHER → slå Instant File Initialization til."),
|
||||||
|
new("LATCH_*", "Konkurrence om interne strukturer i SQL Server",
|
||||||
|
"Latches beskytter interne strukturer i hukommelsen (ikke datasider). LATCH_EX og LATCH_SH er ventetid på dem.",
|
||||||
|
"Små mængder er normale.",
|
||||||
|
"Typisk ACCESS_METHODS_DATASET_PARENT (parallelle scans) eller FGCB_ADD_REMOVE (filvækst). Hvilken latch det er, står i sys.dm_os_latch_stats.",
|
||||||
|
"Se sys.dm_os_latch_stats for hvilken latch det er. Parallelle scans: se CXPACKET. Filvækst: sæt passende autogrowth og Instant File Initialization."),
|
||||||
|
new("MSQL_XP", "Venter på en extended stored procedure",
|
||||||
|
"SQL Server venter på, at en extended stored procedure (xp_…) bliver færdig, fx xp_cmdshell eller tredjeparts backupværktøjer.",
|
||||||
|
"Normalt for job, der bruger xp_cmdshell, og for visse backupværktøjer.",
|
||||||
|
"Lange kommandoer kørt med xp_cmdshell, tredjepartsudvidelser, der hænger.",
|
||||||
|
"Find hvilken procedure det er (forespørgslerne nedenfor) og om ventetiden svarer til, at den gør noget langvarigt."),
|
||||||
|
new("WAIT_ON_SYNC_STATISTICS_REFRESH", "Forespørgsler venter på, at statistik bliver opdateret",
|
||||||
|
"En forespørgsel skal bruge en statistik, der er forældet, og venter på, at SQL Server opdaterer den, før den kan kompilere.",
|
||||||
|
"Lidt er normalt efter store dataændringer.",
|
||||||
|
"Store tabeller, hvor automatisk statistikopdatering sker midt i travl drift.",
|
||||||
|
"Slå AUTO_UPDATE_STATISTICS_ASYNC til for databasen, så forespørgsler bruger den gamle statistik, mens den nye bygges, og opdatér statistik i et vedligeholdelsesvindue.",
|
||||||
|
"For AX-databaser er AUTO_UPDATE_STATISTICS_ASYNC en almindelig anbefaling sammen med natlig statistikopdatering."),
|
||||||
|
new("SOS_WORK_DISPATCHER", "Ledig worker-tråd venter på arbejde",
|
||||||
|
"En ledig tråd venter på at få tildelt arbejde. Ikke et problem.",
|
||||||
|
"Altid.",
|
||||||
|
"–",
|
||||||
|
"Intet at gøre."),
|
||||||
|
new("WAITFOR", "Forespørgslen venter med vilje (WAITFOR)",
|
||||||
|
"Koden har selv bedt om at vente med WAITFOR DELAY eller WAITFOR RECEIVE.",
|
||||||
|
"Altid, når koden gør det med vilje.",
|
||||||
|
"Kode, job eller Service Broker, der venter med vilje.",
|
||||||
|
"Intet at gøre i SQL Server; se på koden, hvis ventetiden ikke er forventet."),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>The explanation for a wait type: its own, its family's (LCK_M_X → LCK_M_*), or a generic text.</summary>
|
||||||
|
public static WaitExplanation Lookup(string waitType)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(waitType);
|
||||||
|
var w = waitType.Trim().ToUpperInvariant();
|
||||||
|
foreach (var entry in Families)
|
||||||
|
{
|
||||||
|
foreach (var pattern in entry.Family.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
if (pattern.EndsWith('*') ? w.StartsWith(pattern[..^1], StringComparison.Ordinal) : w == pattern)
|
||||||
|
{
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return w switch
|
||||||
|
{
|
||||||
|
"EXCHANGE" or "CXSYNC_PORT" or "CXSYNC_CONSUMER" => Parallelism,
|
||||||
|
"NETWORKIO" => Lookup("ASYNC_NETWORK_IO"),
|
||||||
|
_ when WaitCatalog.IsBenign(w) => new(w, "Baggrundsventetid uden betydning",
|
||||||
|
$"{w} er en ventetid, som SQL Server's egne baggrundsopgaver har med vilje. Den siger intet om belastningen.",
|
||||||
|
"Altid.", "–", "Intet at gøre."),
|
||||||
|
_ => Generic(w),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WaitExplanation Generic(string waitType) => new(
|
||||||
|
waitType,
|
||||||
|
"Ventetype uden dansk forklaring endnu",
|
||||||
|
$"SQLInsight Pro har endnu ingen forklaring på {waitType}. Kategorien er {Category(waitType)}.",
|
||||||
|
"Vurdér ud fra hvor stor en andel af ventetiden den udgør, og om den er steget i forhold til normalen.",
|
||||||
|
"Se Microsofts beskrivelse af ventetypen (linket nedenfor) og docs/analyse.md.",
|
||||||
|
"Se på de forespørgsler, der venter på den (nedenfor), og om ventetiden følger et bestemt tidspunkt eller job.",
|
||||||
|
Known: false);
|
||||||
|
|
||||||
|
private static string Category(string waitType) => WaitCatalog.Categorize(waitType) switch
|
||||||
|
{
|
||||||
|
WaitCategory.Cpu => "CPU",
|
||||||
|
WaitCategory.WorkerThreads => "worker threads",
|
||||||
|
WaitCategory.Parallelism => "parallelisme",
|
||||||
|
WaitCategory.Locks => "låse",
|
||||||
|
WaitCategory.BufferIo => "læsning fra disk",
|
||||||
|
WaitCategory.PageLatch => "sider i hukommelsen",
|
||||||
|
WaitCategory.Latch => "interne latches",
|
||||||
|
WaitCategory.TransactionLog => "transaktionsloggen",
|
||||||
|
WaitCategory.Memory => "hukommelse",
|
||||||
|
WaitCategory.Network => "netværk og klient",
|
||||||
|
WaitCategory.OtherIo => "anden I/O",
|
||||||
|
WaitCategory.AvailabilityGroups => "Availability Groups",
|
||||||
|
WaitCategory.Preemptive => "Windows/eksterne kald",
|
||||||
|
_ => "anden",
|
||||||
|
};
|
||||||
|
}
|
||||||
67
src/SQLInsight.Core/Data/WaitSampling.cs
Normal file
67
src/SQLInsight.Core/Data/WaitSampling.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
namespace SQLInsight.Core.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One user request that was waiting when a sample was taken (sys.dm_exec_requests). <see cref="QueryKey"/> is the same
|
||||||
|
/// key as top SQL uses (query_hash, or on SQL Server 2005 the batch and offsets), so the statement's text and use can
|
||||||
|
/// be joined. <see cref="Text"/> is customer data: kept locally only.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record WaitingRequest(int SessionId, string WaitType, long WaitTimeMs, string? DatabaseName, string? QueryKey, string? Text, int BlockingSessionId);
|
||||||
|
|
||||||
|
/// <summary>The waiting requests of one sample, summed by wait type, database and statement.</summary>
|
||||||
|
/// <param name="Sessions">How many requests were waiting on it at the moment of the sample.</param>
|
||||||
|
/// <param name="WaitTimeMs">Their wait time so far (sum).</param>
|
||||||
|
/// <param name="BlockedSessions">How many of them were blocked by another session.</param>
|
||||||
|
public sealed record WaitSampleRow(string WaitType, string? DatabaseName, string? QueryKey, int Sessions, long WaitTimeMs, long MaxWaitTimeMs, int BlockedSessions);
|
||||||
|
|
||||||
|
/// <summary>Active session sampling: what user sessions were waiting on, sampled every 15 seconds.</summary>
|
||||||
|
public sealed record WaitSampleSnapshot
|
||||||
|
{
|
||||||
|
public required DateTimeOffset CapturedAt { get; init; }
|
||||||
|
public required IReadOnlyList<WaitSampleRow> Rows { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Statement texts by query key (customer data; only stored locally, never as a JSON snapshot).</summary>
|
||||||
|
public IReadOnlyList<SampledStatement> Statements { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SampledStatement(string QueryKey, string? DatabaseName, string? Text);
|
||||||
|
|
||||||
|
public static class WaitSampling
|
||||||
|
{
|
||||||
|
/// <summary>How often a sample is taken.</summary>
|
||||||
|
public static readonly TimeSpan Interval = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
|
/// <summary>Program name prefix of the agent's own connections, which are never sampled.</summary>
|
||||||
|
public const string AgentProgramPrefix = "SQLInsight Pro";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sums one sample's waiting requests by wait type, database and statement. Background waits that mean nothing
|
||||||
|
/// (see <see cref="WaitCatalog.IsBenign"/>) are left out.
|
||||||
|
/// </summary>
|
||||||
|
public static WaitSampleSnapshot Aggregate(IEnumerable<WaitingRequest> requests, DateTimeOffset at)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(requests);
|
||||||
|
var waiting = requests.Where(r => !string.IsNullOrEmpty(r.WaitType) && !WaitCatalog.IsBenign(r.WaitType)).ToList();
|
||||||
|
var rows = waiting
|
||||||
|
.GroupBy(r => (WaitType: r.WaitType.ToUpperInvariant(), r.DatabaseName, r.QueryKey))
|
||||||
|
.Select(g => new WaitSampleRow(g.Key.WaitType, g.Key.DatabaseName, g.Key.QueryKey, g.Count(),
|
||||||
|
g.Sum(r => Math.Max(0, r.WaitTimeMs)), g.Max(r => Math.Max(0, r.WaitTimeMs)), g.Count(r => r.BlockingSessionId > 0)))
|
||||||
|
.OrderByDescending(r => r.Sessions).ThenBy(r => r.WaitType, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
var statements = waiting
|
||||||
|
.Where(r => r.QueryKey is not null)
|
||||||
|
.GroupBy(r => r.QueryKey!, StringComparer.Ordinal)
|
||||||
|
.Select(g => new SampledStatement(g.Key, g.Select(r => r.DatabaseName).FirstOrDefault(d => d is not null),
|
||||||
|
Cut(g.Select(r => r.Text).FirstOrDefault(t => t is not null))))
|
||||||
|
.ToList();
|
||||||
|
return new WaitSampleSnapshot { CapturedAt = at, Rows = rows, Statements = statements };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Cut(string? text) => text is { Length: > QueryStat.MaxTextLength } ? text[..QueryStat.MaxTextLength] : text;
|
||||||
|
|
||||||
|
/// <summary>Lock waits, where the blocking chain explains the wait.</summary>
|
||||||
|
public static bool IsLockWait(string waitType)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(waitType);
|
||||||
|
return waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -82,6 +82,7 @@ public sealed partial class InsightRepository
|
||||||
await DeleteInBatchesAsync(connection, "insight.WaitSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
|
await DeleteInBatchesAsync(connection, "insight.WaitSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
|
||||||
await DeleteInBatchesAsync(connection, "insight.FileIoSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
|
await DeleteInBatchesAsync(connection, "insight.FileIoSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
|
||||||
await DeleteInBatchesAsync(connection, "insight.QueryStatSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
|
await DeleteInBatchesAsync(connection, "insight.QueryStatSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
|
||||||
|
await DeleteInBatchesAsync(connection, "insight.ActiveWaitSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
|
||||||
await DeleteInBatchesAsync(connection, "insight.QueryTexts", "LastSeenAt < @cutoff", raw).ConfigureAwait(false);
|
await DeleteInBatchesAsync(connection, "insight.QueryTexts", "LastSeenAt < @cutoff", raw).ConfigureAwait(false);
|
||||||
await DeleteInBatchesAsync(connection, "insight.MetricHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
|
await DeleteInBatchesAsync(connection, "insight.MetricHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
|
||||||
await DeleteInBatchesAsync(connection, "insight.WaitHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
|
await DeleteInBatchesAsync(connection, "insight.WaitHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
|
||||||
|
|
|
||||||
93
src/SQLInsight.Repository/InsightRepository.Waits.cs
Normal file
93
src/SQLInsight.Repository/InsightRepository.Waits.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
|
||||||
|
namespace SQLInsight.Repository;
|
||||||
|
|
||||||
|
/// <summary>A statement that was seen waiting on a wait type, summed over the sampled period.</summary>
|
||||||
|
/// <param name="Samples">Requests seen waiting (one per request per 15-second sample).</param>
|
||||||
|
/// <param name="Share">Its part of all samples of the wait type in the period.</param>
|
||||||
|
/// <param name="EstimatedWaitSeconds">Samples × 15 s: an estimate of the time spent waiting.</param>
|
||||||
|
/// <param name="AverageWaitMs">How long the requests had waited when sampled, on average.</param>
|
||||||
|
public sealed record WaitTypeStatement(string? QueryKey, string? DatabaseName, string? Text, long Samples, double Share,
|
||||||
|
double EstimatedWaitSeconds, double AverageWaitMs, long MaxWaitMs, long BlockedSamples);
|
||||||
|
|
||||||
|
/// <summary>A stored statement text (customer data, local UI only).</summary>
|
||||||
|
public sealed record StoredQueryText(string QueryKey, string? DatabaseName, string? QueryText, DateTime FirstSeenAt, DateTime LastSeenAt);
|
||||||
|
|
||||||
|
public sealed partial class InsightRepository
|
||||||
|
{
|
||||||
|
private static async Task SaveWaitSamplesAsync(SqlConnection connection, int instanceId, WaitSampleSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (snapshot.Rows.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.ExecuteAsync("""
|
||||||
|
INSERT insight.ActiveWaitSamples (InstanceId, SampledAt, WaitType, DatabaseName, QueryKey, Sessions, WaitTimeMs, MaxWaitTimeMs, BlockedSessions)
|
||||||
|
SELECT @instanceId, @at, WaitType, DatabaseName, QueryKey, Sessions, WaitTimeMs, MaxWaitTimeMs, BlockedSessions
|
||||||
|
FROM OPENJSON(@rows) WITH (WaitType nvarchar(60), DatabaseName nvarchar(128), QueryKey varchar(200), Sessions int,
|
||||||
|
WaitTimeMs bigint, MaxWaitTimeMs bigint, BlockedSessions int);
|
||||||
|
|
||||||
|
MERGE insight.QueryTexts AS t
|
||||||
|
USING (SELECT QueryKey, MAX(DatabaseName) AS DatabaseName, MAX([Text]) AS [Text]
|
||||||
|
FROM OPENJSON(@statements) WITH (QueryKey varchar(200), DatabaseName nvarchar(128), [Text] nvarchar(4000)) GROUP BY QueryKey) AS s
|
||||||
|
ON t.InstanceId = @instanceId AND t.QueryKey = s.QueryKey
|
||||||
|
WHEN MATCHED THEN UPDATE SET LastSeenAt = @at, QueryText = COALESCE(t.QueryText, s.[Text]), DatabaseName = COALESCE(t.DatabaseName, s.DatabaseName)
|
||||||
|
WHEN NOT MATCHED THEN INSERT (InstanceId, QueryKey, DatabaseName, QueryText, FirstSeenAt, LastSeenAt) VALUES (@instanceId, s.QueryKey, s.DatabaseName, s.[Text], @at, @at);
|
||||||
|
""", new
|
||||||
|
{
|
||||||
|
instanceId,
|
||||||
|
at = Utc(snapshot.CapturedAt),
|
||||||
|
rows = JsonSerializer.Serialize(snapshot.Rows, Json),
|
||||||
|
statements = JsonSerializer.Serialize(snapshot.Statements, Json),
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The statements seen waiting on a wait type in (from, to], most samples first.</summary>
|
||||||
|
public async Task<IReadOnlyList<WaitTypeStatement>> GetWaitTypeStatementsAsync(int instanceId, string waitType, DateTimeOffset from, DateTimeOffset to,
|
||||||
|
int top = 25, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var rows = await connection.QueryAsync<(string? QueryKey, string? DatabaseName, string? Text, long Samples, long WaitTimeMs, long MaxWaitMs, long Blocked, long Total)>("""
|
||||||
|
WITH s AS (
|
||||||
|
SELECT QueryKey, DatabaseName, SUM(CAST(Sessions AS bigint)) AS Samples, SUM(WaitTimeMs) AS WaitTimeMs,
|
||||||
|
MAX(MaxWaitTimeMs) AS MaxWaitMs, SUM(CAST(BlockedSessions AS bigint)) AS Blocked
|
||||||
|
FROM insight.ActiveWaitSamples
|
||||||
|
WHERE InstanceId = @instanceId AND WaitType = @waitType AND SampledAt > @from AND SampledAt <= @to
|
||||||
|
GROUP BY QueryKey, DatabaseName)
|
||||||
|
SELECT TOP (@top) s.QueryKey, COALESCE(s.DatabaseName, t.DatabaseName), t.QueryText, s.Samples, s.WaitTimeMs, s.MaxWaitMs, s.Blocked,
|
||||||
|
SUM(s.Samples) OVER ()
|
||||||
|
FROM s LEFT JOIN insight.QueryTexts t ON t.InstanceId = @instanceId AND t.QueryKey = s.QueryKey
|
||||||
|
ORDER BY s.Samples DESC, s.WaitTimeMs DESC;
|
||||||
|
""", new { instanceId, waitType, from = from.UtcDateTime, to = to.UtcDateTime, top }).ConfigureAwait(false);
|
||||||
|
return rows.Select(r => new WaitTypeStatement(r.QueryKey, r.DatabaseName, r.Text, r.Samples, r.Total == 0 ? 0 : (double)r.Samples / r.Total,
|
||||||
|
r.Samples * WaitSampling.Interval.TotalSeconds, r.Samples == 0 ? 0 : (double)r.WaitTimeMs / r.Samples, r.MaxWaitMs, r.Blocked)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A wait type's wait time per minute over time: the 5-minute samples when the window is within the raw data, else
|
||||||
|
/// hourly values.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyList<MetricPoint>> GetWaitTypeSeriesAsync(int instanceId, string waitType, DateTimeOffset from, DateTimeOffset to,
|
||||||
|
bool hourly, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var sql = hourly
|
||||||
|
? "SELECT [Hour] AS At, CAST(WaitTimeMs AS float) / 60 AS Value FROM insight.WaitHourly WHERE InstanceId = @instanceId AND WaitType = @waitType AND [Hour] >= @hourFrom AND [Hour] < @to ORDER BY [Hour];"
|
||||||
|
: "SELECT SampledAt AS At, CAST(WaitTimeMs AS float) * 60 / IntervalSeconds AS Value FROM insight.WaitSamples WHERE InstanceId = @instanceId AND WaitType = @waitType AND IntervalSeconds > 0 AND SampledAt > @from AND SampledAt <= @to ORDER BY SampledAt;";
|
||||||
|
var rows = await connection.QueryAsync<MetricPoint>(sql, new { instanceId, waitType, from = from.UtcDateTime, hourFrom = FloorHour(from), to = to.UtcDateTime })
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StoredQueryText?> GetQueryTextAsync(int instanceId, string queryKey, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<StoredQueryText>(
|
||||||
|
"SELECT QueryKey, DatabaseName, QueryText, FirstSeenAt, LastSeenAt FROM insight.QueryTexts WHERE InstanceId = @instanceId AND QueryKey = @queryKey;",
|
||||||
|
new { instanceId, queryKey }).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -256,6 +256,10 @@ public sealed partial class InsightRepository(string connectionString)
|
||||||
await SaveConfigurationChangesAsync(connection, instanceId, configuration.Changes).ConfigureAwait(false);
|
await SaveConfigurationChangesAsync(connection, instanceId, configuration.Changes).ConfigureAwait(false);
|
||||||
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
|
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
|
||||||
return;
|
return;
|
||||||
|
case WaitSampleSnapshot samples:
|
||||||
|
// Never as a JSON snapshot: it holds statement texts.
|
||||||
|
await SaveWaitSamplesAsync(connection, instanceId, samples).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
case PrivilegeSnapshot privileges:
|
case PrivilegeSnapshot privileges:
|
||||||
await SavePrivilegeChangesAsync(connection, instanceId, privileges.Changes).ConfigureAwait(false);
|
await SavePrivilegeChangesAsync(connection, instanceId, privileges.Changes).ConfigureAwait(false);
|
||||||
await SaveLatePrivilegeAuthorsAsync(connection, instanceId, privileges.RecentChanges).ConfigureAwait(false);
|
await SaveLatePrivilegeAuthorsAsync(connection, instanceId, privileges.RecentChanges).ConfigureAwait(false);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/* Active session sampling: what waiting user requests waited on every 15 seconds, by wait type, database and
|
||||||
|
statement (QueryKey as in insight.QueryTexts). Kept as long as the other raw samples (Retention:RawDays). */
|
||||||
|
CREATE TABLE insight.ActiveWaitSamples
|
||||||
|
(
|
||||||
|
InstanceId int NOT NULL,
|
||||||
|
SampledAt datetime2(0) NOT NULL,
|
||||||
|
WaitType nvarchar(60) NOT NULL,
|
||||||
|
DatabaseName nvarchar(128) NULL,
|
||||||
|
QueryKey varchar(200) NULL,
|
||||||
|
Sessions int NOT NULL,
|
||||||
|
WaitTimeMs bigint NOT NULL,
|
||||||
|
MaxWaitTimeMs bigint NOT NULL,
|
||||||
|
BlockedSessions int NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE CLUSTERED INDEX CX_ActiveWaitSamples ON insight.ActiveWaitSamples (InstanceId, SampledAt);
|
||||||
|
CREATE INDEX IX_ActiveWaitSamples_WaitType ON insight.ActiveWaitSamples (InstanceId, WaitType, SampledAt)
|
||||||
|
INCLUDE (DatabaseName, QueryKey, Sessions, WaitTimeMs, MaxWaitTimeMs, BlockedSessions);
|
||||||
|
|
@ -97,41 +97,29 @@ public static class WaitRules
|
||||||
new DominantWaitRule(
|
new DominantWaitRule(
|
||||||
"SQLI-WAIT-003", WaitCategory.Locks, 0.2, 0.5, 5,
|
"SQLI-WAIT-003", WaitCategory.Locks, 0.2, 0.5, 5,
|
||||||
"Sessioner venter meget på låse",
|
"Sessioner venter meget på låse",
|
||||||
"En stor del af ventetiden er LCK_M_*: sessioner venter på låse, som andre sessioner holder. Det skyldes typisk "
|
WaitKnowledge.Locks.Meaning,
|
||||||
+ "lange transaktioner, manglende indexes (så opdateringer låser flere rækker end nødvendigt) eller "
|
WaitKnowledge.Locks.Actions,
|
||||||
+ "læsninger, der blokeres af skrivninger.",
|
|
||||||
"Se blocking-fundene for, hvem der blokerer. Hold transaktioner korte, tilføj indexes til WHERE-betingelserne i "
|
|
||||||
+ "opdateringer, og overvej READ_COMMITTED_SNAPSHOT, så læsere ikke blokeres af skrivere (test først: det "
|
|
||||||
+ "belaster tempdb og ændrer adfærd for kode, der er afhængig af låse).",
|
|
||||||
Docs.Learn("Transaction locking guide", "relational-databases/sql-server-transaction-locking-and-row-versioning-guide")),
|
Docs.Learn("Transaction locking guide", "relational-databases/sql-server-transaction-locking-and-row-versioning-guide")),
|
||||||
|
|
||||||
new DominantWaitRule(
|
new DominantWaitRule(
|
||||||
"SQLI-WAIT-004", WaitCategory.BufferIo, 0.3, 1, 8,
|
"SQLI-WAIT-004", WaitCategory.BufferIo, 0.3, 1, 8,
|
||||||
"Forespørgsler venter meget på at læse data fra disk",
|
"Forespørgsler venter meget på at læse data fra disk",
|
||||||
"PAGEIOLATCH-ventetider betyder, at sider skal læses fra disk, fordi de ikke er i buffer pool. Årsagen er "
|
WaitKnowledge.BufferIo.Meaning,
|
||||||
+ "enten for lidt hukommelse, forespørgsler der læser langt mere end nødvendigt (scans, manglende indexes), "
|
WaitKnowledge.BufferIo.Actions,
|
||||||
+ "eller langsom storage. Høj ventetid er et symptom; årsagen skal findes.",
|
|
||||||
"Tjek først I/O-latens pr. fil (hvis den er lav, er problemet mængden af læsninger, ikke disken). Find de "
|
|
||||||
+ "forespørgsler, der læser mest, i Query Store eller sys.dm_exec_query_stats, og se på deres planer og indexes. "
|
|
||||||
+ "Tjek derefter, om max server memory er sat fornuftigt.",
|
|
||||||
WaitStatsDocs),
|
WaitStatsDocs),
|
||||||
|
|
||||||
new DominantWaitRule(
|
new DominantWaitRule(
|
||||||
"SQLI-WAIT-006", WaitCategory.Parallelism, 0.3, 2, 16,
|
"SQLI-WAIT-006", WaitCategory.Parallelism, 0.3, 2, 16,
|
||||||
"Parallelle forespørgsler venter meget på hinanden",
|
"Parallelle forespørgsler venter meget på hinanden",
|
||||||
"CXPACKET er i sig selv normalt for parallelle planer. Når det dominerer, betyder det typisk mange små "
|
WaitKnowledge.Parallelism.Meaning,
|
||||||
+ "forespørgsler, der unødigt går parallelt, eller skæv fordeling af rækker mellem trådene.",
|
WaitKnowledge.Parallelism.Actions,
|
||||||
"Tjek cost threshold for parallelism og MAXDOP (se konfigurationsfund). Find de parallelle forespørgsler med "
|
|
||||||
+ "højest CPU i Query Store; ofte fjerner et bedre index behovet for parallelisme.",
|
|
||||||
WaitStatsDocs),
|
WaitStatsDocs),
|
||||||
|
|
||||||
new DominantWaitRule(
|
new DominantWaitRule(
|
||||||
"SQLI-WAIT-007", WaitCategory.PageLatch, 0.2, 0.5, 4,
|
"SQLI-WAIT-007", WaitCategory.PageLatch, 0.2, 0.5, 4,
|
||||||
"Contention på sider i hukommelsen (PAGELATCH)",
|
"Contention på sider i hukommelsen (PAGELATCH)",
|
||||||
"PAGELATCH-ventetider er konkurrence om sider, der allerede er i hukommelsen. De to klassiske årsager er "
|
WaitKnowledge.PageLatch.Meaning,
|
||||||
+ "allokeringssider i tempdb (mange midlertidige objekter) og 'last page insert' på et index med stigende nøgle.",
|
WaitKnowledge.PageLatch.Actions,
|
||||||
"Tjek wait_resource i sys.dm_exec_requests under belastning: 2:x:y betyder tempdb (tilføj lige store datafiler, "
|
|
||||||
+ "se tempdb-fund). Ved en brugertabel: overvej OPTIMIZE_FOR_SEQUENTIAL_KEY (2019+) eller en anden nøgle.",
|
|
||||||
Docs.Learn("tempdb", "relational-databases/databases/tempdb-database")),
|
Docs.Learn("tempdb", "relational-databases/databases/tempdb-database")),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@
|
||||||
@foreach (var w in _analysis.Waits.Take(15))
|
@foreach (var w in _analysis.Waits.Take(15))
|
||||||
{
|
{
|
||||||
<tr class="@(w.IsUnusual ? "unusual" : "")">
|
<tr class="@(w.IsUnusual ? "unusual" : "")">
|
||||||
<td class="mono">@w.WaitType</td>
|
<td class="mono"><a href="@WaitUrl(w.WaitType)">@w.WaitType</a></td>
|
||||||
<td>@w.Category</td>
|
<td>@w.Category</td>
|
||||||
<td class="num">@Display.Number(w.WaitTimeMs / 1000.0, 1) s</td>
|
<td class="num">@Display.Number(w.WaitTimeMs / 1000.0, 1) s</td>
|
||||||
<td class="num">@Display.Number(w.PerMinute / 1000.0, 2) s</td>
|
<td class="num">@Display.Number(w.PerMinute / 1000.0, 2) s</td>
|
||||||
|
|
@ -103,7 +103,7 @@
|
||||||
@foreach (var q in _analysis.TopQueries)
|
@foreach (var q in _analysis.TopQueries)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="muted small">@q.DatabaseName</span><pre class="sql">@(q.Text ?? "(teksten er ikke længere i plan cache)")</pre></td>
|
<td><span class="muted small">@q.DatabaseName</span><pre class="sql">@(q.Text ?? "(teksten er ikke længere i plan cache)")</pre><a class="small" href="@($"{InstanceUrl}/sql?noegle={Uri.EscapeDataString(q.QueryKey)}")">Hele teksten</a></td>
|
||||||
<td class="num nowrap">@Display.Number(q.WorkerTimeUs / 1_000_000.0, 1) s</td>
|
<td class="num nowrap">@Display.Number(q.WorkerTimeUs / 1_000_000.0, 1) s</td>
|
||||||
<td class="num nowrap">@Display.Number(q.ElapsedTimeUs / 1_000_000.0, 1) s</td>
|
<td class="num nowrap">@Display.Number(q.ElapsedTimeUs / 1_000_000.0, 1) s</td>
|
||||||
<td class="num">@Display.Number(q.LogicalReads)</td>
|
<td class="num">@Display.Number(q.LogicalReads)</td>
|
||||||
|
|
@ -150,7 +150,7 @@
|
||||||
|
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-head"><h2>Blocking</h2></div>
|
<div class="card-head" id="blocking"><h2>Blocking</h2></div>
|
||||||
@if (_analysis.Blocking.Count == 0)
|
@if (_analysis.Blocking.Count == 0)
|
||||||
{
|
{
|
||||||
<p class="muted">Ingen blocking over 5 sekunder i tidsrummet.</p>
|
<p class="muted">Ingen blocking over 5 sekunder i tidsrummet.</p>
|
||||||
|
|
@ -253,5 +253,7 @@
|
||||||
_analysis = await TimeWindowAnalysis.LoadAsync(Repository, _instance, _from, _to, now, new RetentionOptions().RawDays, AnalysisOrder.Parse(Sortering));
|
_analysis = await TimeWindowAnalysis.LoadAsync(Repository, _instance, _from, _to, now, new RetentionOptions().RawDays, AnalysisOrder.Parse(Sortering));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string WaitUrl(string waitType) => $"{InstanceUrl}/ventetyper/{Uri.EscapeDataString(waitType)}?fra={Uri.EscapeDataString(TimeWindow.ToInput(_from, Display.Zone))}&til={Uri.EscapeDataString(TimeWindow.ToInput(_to, Display.Zone))}";
|
||||||
|
|
||||||
private static string Ms(double? value) => value is { } v ? $"{Display.Number(v, 1)} ms" : "–";
|
private static string Ms(double? value) => value is { } v ? $"{Display.Number(v, 1)} ms" : "–";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ else
|
||||||
@foreach (var w in _waits)
|
@foreach (var w in _waits)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono">@w.WaitType</td>
|
<td class="mono"><a href="/instanser/@Uri.EscapeDataString(_instance.Name)/ventetyper/@Uri.EscapeDataString(w.WaitType)">@w.WaitType</a></td>
|
||||||
<td>@w.Category</td>
|
<td>@w.Category</td>
|
||||||
<td class="num">@Display.Number(w.WaitTimeMs / 1000.0, 1) s</td>
|
<td class="num">@Display.Number(w.WaitTimeMs / 1000.0, 1) s</td>
|
||||||
<td class="num"><meter min="0" max="1" value="@(((double)w.WaitTimeMs / total).ToString("0.###", System.Globalization.CultureInfo.InvariantCulture))"></meter> @Display.Number(100.0 * w.WaitTimeMs / total) %</td>
|
<td class="num"><meter min="0" max="1" value="@(((double)w.WaitTimeMs / total).ToString("0.###", System.Globalization.CultureInfo.InvariantCulture))"></meter> @Display.Number(100.0 * w.WaitTimeMs / total) %</td>
|
||||||
|
|
|
||||||
36
src/SQLInsight.Web/Components/Pages/QueryTextPage.razor
Normal file
36
src/SQLInsight.Web/Components/Pages/QueryTextPage.razor
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
@page "/instanser/{Name}/sql"
|
||||||
|
@inject InsightRepository Repository
|
||||||
|
@inject Display Display
|
||||||
|
|
||||||
|
<PageTitle>Sætning · @Name · SQLInsight Pro</PageTitle>
|
||||||
|
<p class="crumbs"><a href="/">Instanser</a> / <a href="/instanser/@Uri.EscapeDataString(Name)">@Name</a> / Sætning</p>
|
||||||
|
<h1>Sætning</h1>
|
||||||
|
|
||||||
|
@if (_text is null)
|
||||||
|
{
|
||||||
|
<p class="muted">Sætningen findes ikke (længere). Tekster gemmes, så længe sætningen ses, og slettes derefter sammen med de øvrige detaljerede målinger.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="lead">@(_text.DatabaseName ?? "Ukendt database") · set første gang @Display.Time(_text.FirstSeenAt), senest @Display.Time(_text.LastSeenAt)</p>
|
||||||
|
<section class="card">
|
||||||
|
<pre class="sql">@(_text.QueryText ?? "(teksten er ikke gemt)")</pre>
|
||||||
|
<p class="muted small mono">@_text.QueryKey</p>
|
||||||
|
</section>
|
||||||
|
<p class="muted">Teksten er kundens data. Den vises kun her og sendes aldrig til SQLInsight Hub. Højst @QueryStat.MaxTextLength tegn gemmes.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Name { get; set; } = string.Empty;
|
||||||
|
[SupplyParameterFromQuery(Name = "noegle")] public string? Noegle { get; set; }
|
||||||
|
|
||||||
|
private StoredQueryText? _text;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (Noegle is { Length: > 0 } key && await Repository.GetInstanceAsync(Name) is { } instance)
|
||||||
|
{
|
||||||
|
_text = await Repository.GetQueryTextAsync(instance.InstanceId, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/SQLInsight.Web/Components/Pages/WaitTypePage.razor
Normal file
140
src/SQLInsight.Web/Components/Pages/WaitTypePage.razor
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
@page "/instanser/{Name}/ventetyper/{WaitType}"
|
||||||
|
@inject InsightRepository Repository
|
||||||
|
@inject Display Display
|
||||||
|
|
||||||
|
@if (_instance is null)
|
||||||
|
{
|
||||||
|
<PageTitle>Ukendt instans · SQLInsight Pro</PageTitle>
|
||||||
|
<h1>Ukendt instans</h1>
|
||||||
|
<p>Der findes ingen instans med navnet <strong>@Name</strong>.</p>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
<PageTitle>@WaitType · @_instance.Name · SQLInsight Pro</PageTitle>
|
||||||
|
<p class="crumbs"><a href="/">Instanser</a> / <a href="@InstanceUrl">@_instance.Name</a> / Ventetyper / @WaitType</p>
|
||||||
|
<h1 class="mono">@WaitType</h1>
|
||||||
|
<p class="lead">@_explanation.Title</p>
|
||||||
|
|
||||||
|
<form method="get" class="panel window-form">
|
||||||
|
<label>Fra <input type="datetime-local" name="fra" value="@TimeWindow.ToInput(_from, Display.Zone)" required /></label>
|
||||||
|
<label>Til <input type="datetime-local" name="til" value="@TimeWindow.ToInput(_to, Display.Zone)" required /></label>
|
||||||
|
<button type="submit" class="button">Vis</button>
|
||||||
|
<span class="presets"><a href="@AnalysisUrl">Analyse af samme tidsrum</a></span>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if (_error is not null)
|
||||||
|
{
|
||||||
|
<div class="alert">@_error</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Hvad betyder det?</h2>@if (!_explanation.Known) { <span class="muted">generel forklaring</span> }</div>
|
||||||
|
<p>@_explanation.Meaning</p>
|
||||||
|
<table class="kv">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>Hvornår er det normalt</th><td>@_explanation.Normal</td></tr>
|
||||||
|
<tr><th>Typiske årsager</th><td>@_explanation.Causes</td></tr>
|
||||||
|
<tr><th>Hvad kan man gøre</th><td>@_explanation.Actions</td></tr>
|
||||||
|
@if (_explanation.DynamicsAx is { } ax)
|
||||||
|
{
|
||||||
|
<tr><th>Dynamics AX</th><td>@ax</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted">Se også <a href="@WaitExplanation.MicrosoftDocs" rel="noopener">Microsofts liste over ventetyper</a> og afsnittet om ventetyper i dokumentationen (docs/analyse.md).</p>
|
||||||
|
@if (WaitSampling.IsLockWait(WaitType))
|
||||||
|
{
|
||||||
|
<p><a class="button" href="@($"{AnalysisUrl}#blocking")">Se blocking i tidsrummet</a></p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Udvikling</h2><span class="muted">ventetid pr. minut@(_hourly ? ", timeværdier" : ", hvert 5. minut")</span></div>
|
||||||
|
<Sparkline Points="_series" Label="@WaitType" Unit=" s/min" Decimals="1" Empty="ingen ventetid målt i tidsrummet." />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Hvilke sætninger venter</h2><span class="muted">stikprøver hvert 15. sekund af brugersessioner; teksten bliver her og sendes aldrig til SQLInsight Hub</span></div>
|
||||||
|
@if (_statements.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="muted">Ingen sessioner set ventende på @WaitType i tidsrummet. Stikprøverne tages fra version 1.0.5 og gemmes i @_rawDays dage; korte ventetider (under 15 sekunder i alt) kan falde mellem to stikprøver.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="queries">
|
||||||
|
<thead><tr><th>Sætning</th><th class="num">Stikprøver</th><th class="num">Andel</th><th class="num">Anslået ventetid</th><th class="num">Gns. ventet</th><th class="num">Blokeret</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var s in _statements)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="muted small">@(s.DatabaseName ?? "–")</span>
|
||||||
|
<pre class="sql">@Excerpt(s.Text)</pre>
|
||||||
|
@if (s.QueryKey is { } key)
|
||||||
|
{
|
||||||
|
<a class="small" href="@($"{InstanceUrl}/sql?noegle={Uri.EscapeDataString(key)}")">Hele teksten</a>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">@Display.Number(s.Samples)</td>
|
||||||
|
<td class="num">@Display.Number(100 * s.Share) %</td>
|
||||||
|
<td class="num nowrap">@Display.Number(s.EstimatedWaitSeconds / 60, 1) min</td>
|
||||||
|
<td class="num nowrap">@Display.Number(s.AverageWaitMs / 1000, 1) s</td>
|
||||||
|
<td class="num">@Display.Number(s.BlockedSamples)</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted">Anslået ventetid = antal stikprøver × 15 sekunder. Gns. ventet = hvor længe sætningen i gennemsnit havde ventet, da stikprøven blev taget.</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private const int ExcerptLength = 400;
|
||||||
|
|
||||||
|
[Parameter] public string Name { get; set; } = string.Empty;
|
||||||
|
[Parameter] public string WaitType { get; set; } = string.Empty;
|
||||||
|
[SupplyParameterFromQuery(Name = "fra")] public string? Fra { get; set; }
|
||||||
|
[SupplyParameterFromQuery(Name = "til")] public string? Til { get; set; }
|
||||||
|
|
||||||
|
private InstanceOverview? _instance;
|
||||||
|
private WaitExplanation _explanation = WaitKnowledge.Lookup("");
|
||||||
|
private DateTimeOffset _from, _to;
|
||||||
|
private string? _error;
|
||||||
|
private bool _hourly;
|
||||||
|
private readonly int _rawDays = new RetentionOptions().RawDays;
|
||||||
|
private IReadOnlyList<MetricPoint> _series = [];
|
||||||
|
private IReadOnlyList<WaitTypeStatement> _statements = [];
|
||||||
|
|
||||||
|
private string InstanceUrl => "/instanser/" + Uri.EscapeDataString(_instance!.Name);
|
||||||
|
|
||||||
|
private string AnalysisUrl => $"{InstanceUrl}/analyse?fra={Uri.EscapeDataString(TimeWindow.ToInput(_from, Display.Zone))}&til={Uri.EscapeDataString(TimeWindow.ToInput(_to, Display.Zone))}";
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
WaitType = WaitType.Trim().ToUpperInvariant();
|
||||||
|
_explanation = WaitKnowledge.Lookup(WaitType);
|
||||||
|
_instance = await Repository.GetInstanceAsync(Name);
|
||||||
|
if (_instance is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
if (Fra is null && Til is null || !TimeWindow.TryParse(Fra, Til, Display.Zone, out _from, out _to, out _error))
|
||||||
|
{
|
||||||
|
(_from, _to) = (now.AddHours(-24), now);
|
||||||
|
}
|
||||||
|
|
||||||
|
_hourly = TimeWindow.ChooseResolution(_from, now, _rawDays) == WindowResolution.Hourly;
|
||||||
|
_series = (await Repository.GetWaitTypeSeriesAsync(_instance.InstanceId, WaitType, _from, _to, _hourly))
|
||||||
|
.Select(p => p with { Value = p.Value / 1000 }).ToList();
|
||||||
|
_statements = await Repository.GetWaitTypeStatementsAsync(_instance.InstanceId, WaitType, _from, _to);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Excerpt(string? text) => text switch
|
||||||
|
{
|
||||||
|
null => "(teksten er ikke gemt)",
|
||||||
|
{ Length: > ExcerptLength } => text[..ExcerptLength] + " …",
|
||||||
|
_ => text,
|
||||||
|
};
|
||||||
|
}
|
||||||
68
tests/SQLInsight.Core.Tests/WaitSamplingTests.cs
Normal file
68
tests/SQLInsight.Core.Tests/WaitSamplingTests.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
|
||||||
|
namespace SQLInsight.Core.Tests;
|
||||||
|
|
||||||
|
public class WaitSamplingTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset Now = new(2026, 10, 2, 8, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("LCK_M_X", "LCK_M_*")]
|
||||||
|
[InlineData("lck_m_sch_m", "LCK_M_*")]
|
||||||
|
[InlineData("PAGEIOLATCH_SH", "PAGEIOLATCH_*")]
|
||||||
|
[InlineData("CXPACKET", "CXPACKET, CXCONSUMER, CXSYNC_*")]
|
||||||
|
[InlineData("CXSYNC_PORT", "CXPACKET, CXCONSUMER, CXSYNC_*")]
|
||||||
|
[InlineData("RESOURCE_SEMAPHORE_QUERY_COMPILE", "RESOURCE_SEMAPHORE_QUERY_COMPILE")]
|
||||||
|
[InlineData("RESOURCE_SEMAPHORE", "RESOURCE_SEMAPHORE")]
|
||||||
|
[InlineData("PREEMPTIVE_OS_AUTHENTICATIONOPS", "PREEMPTIVE_*")]
|
||||||
|
[InlineData("BACKUPBUFFER", "BACKUPIO, BACKUPBUFFER")]
|
||||||
|
[InlineData("LATCH_EX", "LATCH_*")]
|
||||||
|
public void Wait_types_find_their_family(string waitType, string family)
|
||||||
|
{
|
||||||
|
var explanation = WaitKnowledge.Lookup(waitType);
|
||||||
|
|
||||||
|
Assert.True(explanation.Known);
|
||||||
|
Assert.Equal(family, explanation.Family);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(explanation.Actions));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parallelism_mentions_maxdop_1_for_dynamics_ax_and_shares_the_rule_text()
|
||||||
|
{
|
||||||
|
var cx = WaitKnowledge.Lookup("CXPACKET");
|
||||||
|
|
||||||
|
Assert.Contains("MAXDOP 1", cx.DynamicsAx, StringComparison.Ordinal);
|
||||||
|
Assert.Same(WaitKnowledge.Parallelism, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_wait_types_get_a_generic_text_with_their_category()
|
||||||
|
{
|
||||||
|
var unknown = WaitKnowledge.Lookup("SOME_NEW_WAIT_2027");
|
||||||
|
|
||||||
|
Assert.False(unknown.Known);
|
||||||
|
Assert.Contains("SOME_NEW_WAIT_2027", unknown.Meaning, StringComparison.Ordinal);
|
||||||
|
Assert.Equal("Baggrundsventetid uden betydning", WaitKnowledge.Lookup("LAZYWRITER_SLEEP").Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Samples_are_summed_by_wait_type_database_and_statement_without_background_waits()
|
||||||
|
{
|
||||||
|
var requests = new[]
|
||||||
|
{
|
||||||
|
new WaitingRequest(51, "LCK_M_S", 2000, "AX", "0xAA", "SELECT * FROM INVENTTRANS", 60),
|
||||||
|
new WaitingRequest(52, "lck_m_s", 500, "AX", "0xAA", "SELECT * FROM INVENTTRANS", 60),
|
||||||
|
new WaitingRequest(53, "PAGEIOLATCH_SH", 12, "AX", "0xBB", null, 0),
|
||||||
|
new WaitingRequest(54, "SLEEP_TASK", 1000, null, null, null, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
var snapshot = WaitSampling.Aggregate(requests, Now);
|
||||||
|
|
||||||
|
Assert.Equal(2, snapshot.Rows.Count);
|
||||||
|
var locks = snapshot.Rows[0];
|
||||||
|
Assert.Equal(("LCK_M_S", 2, 2500L, 2000L, 2), (locks.WaitType, locks.Sessions, locks.WaitTimeMs, locks.MaxWaitTimeMs, locks.BlockedSessions));
|
||||||
|
Assert.Equal(2, snapshot.Statements.Count);
|
||||||
|
Assert.Equal("SELECT * FROM INVENTTRANS", snapshot.Statements.Single(s => s.QueryKey == "0xAA").Text);
|
||||||
|
Assert.True(WaitSampling.IsLockWait("LCK_M_IX"));
|
||||||
|
}
|
||||||
|
}
|
||||||
130
tests/SQLInsight.IntegrationTests/WaitSamplingTests.cs
Normal file
130
tests/SQLInsight.IntegrationTests/WaitSamplingTests.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SQLInsight.Collectors.Performance;
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
using SQLInsight.Repository;
|
||||||
|
|
||||||
|
namespace SQLInsight.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>Active session sampling against real SQL Server, and the wait type page.</summary>
|
||||||
|
[Collection(SqlServerTestGroup.Name)]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public class WaitSamplingTests(SqlServerFixture sql)
|
||||||
|
{
|
||||||
|
private async Task<SqlConnection> OpenAsync(string application)
|
||||||
|
{
|
||||||
|
var connection = new SqlConnection(new SqlConnectionStringBuilder(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase))
|
||||||
|
{
|
||||||
|
ApplicationName = application,
|
||||||
|
CommandTimeout = 120,
|
||||||
|
}.ConnectionString);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_blocked_session_is_sampled_with_its_wait_type_and_statement_but_the_agent_is_not()
|
||||||
|
{
|
||||||
|
var table = "WaitSample_" + Guid.NewGuid().ToString("N")[..6];
|
||||||
|
await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; CREATE TABLE dbo.{table} (Id int PRIMARY KEY, Name nvarchar(20)); INSERT dbo.{table} VALUES (1, N'a');");
|
||||||
|
await using var holder = await OpenAsync("Lock holder");
|
||||||
|
await using var transaction = holder.BeginTransaction();
|
||||||
|
await using (var update = new SqlCommand($"UPDATE dbo.{table} SET Name = N'b' WHERE Id = 1;", holder, transaction))
|
||||||
|
{
|
||||||
|
await update.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var user = await OpenAsync("AX32");
|
||||||
|
await using var agentLike = await OpenAsync("SQLInsight Pro (test)");
|
||||||
|
var blocked = new SqlCommand($"SELECT Name FROM dbo.{table} WHERE Id = 1;", user).ExecuteScalarAsync();
|
||||||
|
var ignored = new SqlCommand($"SELECT Name FROM dbo.{table} WHERE Id = 1;", agentLike).ExecuteScalarAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var session = await AgentSession.OpenAsync(sql);
|
||||||
|
WaitSampleSnapshot snapshot;
|
||||||
|
var attempts = 0;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
await Task.Delay(500);
|
||||||
|
snapshot = await session.CollectAsync<WaitSampleSnapshot>(new WaitSamplingCollector());
|
||||||
|
}
|
||||||
|
while (snapshot.Rows.All(r => r.WaitType != "LCK_M_S") && ++attempts < 20);
|
||||||
|
|
||||||
|
var row = Assert.Single(snapshot.Rows, r => r.WaitType == "LCK_M_S");
|
||||||
|
Assert.Equal(1, row.Sessions); // the "SQLInsight Pro" connection waits too, but is the agent's own kind
|
||||||
|
Assert.Equal(1, row.BlockedSessions);
|
||||||
|
Assert.Equal(SqlServerFixture.TestDatabase, row.DatabaseName);
|
||||||
|
Assert.NotNull(row.QueryKey);
|
||||||
|
var statement = Assert.Single(snapshot.Statements, s => s.QueryKey == row.QueryKey);
|
||||||
|
Assert.Contains(table, statement.Text, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
// The same key as top SQL, so the text and use can be joined.
|
||||||
|
Assert.StartsWith("0x", row.QueryKey, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
await Task.WhenAll(blocked, ignored);
|
||||||
|
await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; DROP TABLE dbo.{table};");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wait_type_page_shows_the_explanation_trend_and_statements()
|
||||||
|
{
|
||||||
|
var database = "SQLInsightWait_" + Guid.NewGuid().ToString("N")[..8];
|
||||||
|
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{database}];");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var repository = new InsightRepository(sql.DatabaseConnectionString(database));
|
||||||
|
await repository.MigrateAsync();
|
||||||
|
var info = new InstanceInfo
|
||||||
|
{
|
||||||
|
ServerName = "AX01",
|
||||||
|
Version = SqlServerVersion.Parse("16.0.4135.4"),
|
||||||
|
ProductLevel = "RTM",
|
||||||
|
Edition = "Standard Edition",
|
||||||
|
EngineEdition = EngineEdition.Standard,
|
||||||
|
};
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var id = await repository.UpsertInstanceAsync(@"AX01\AX30", info, "SqlClient", false, now);
|
||||||
|
for (var i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
await repository.SaveCollectedAsync(id, "wait-sampling", WaitSampling.Aggregate(
|
||||||
|
[
|
||||||
|
new WaitingRequest(60 + i, "LCK_M_U", 3000, "AXDB", "0x1234ABCD", "UPDATE NUMBERSEQUENCETABLE SET NEXTREC = @P1", 55),
|
||||||
|
new WaitingRequest(70, "LCK_M_U", 1000, "AXDB", "0x9999", "SELECT 1", 0),
|
||||||
|
], now.AddSeconds(-15 * i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
||||||
|
{
|
||||||
|
b.UseEnvironment("Development");
|
||||||
|
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(database));
|
||||||
|
b.UseSetting("SQLInsight:Web:Authentication", "None");
|
||||||
|
});
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var name = Uri.EscapeDataString(@"AX01\AX30");
|
||||||
|
var page = await client.GetStringAsync($"/instanser/{name}/ventetyper/LCK_M_U");
|
||||||
|
var text = await client.GetStringAsync($"/instanser/{name}/sql?noegle=0x1234ABCD");
|
||||||
|
var unknown = await client.GetStringAsync($"/instanser/{name}/ventetyper/NEW_WAIT_X");
|
||||||
|
|
||||||
|
Assert.Contains("Venter på en lås", page, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("NUMBERSEQUENCETABLE", page, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("50 %", page, StringComparison.Ordinal); // 4 of 8 samples
|
||||||
|
Assert.Contains("#blocking", page, StringComparison.Ordinal); // lock waits link to blocking
|
||||||
|
Assert.Contains("sql?noegle=0x1234ABCD", page, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("UPDATE NUMBERSEQUENCETABLE", text, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("uden dansk forklaring", unknown, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
var statements = await repository.GetWaitTypeStatementsAsync(id, "LCK_M_U", now.AddMinutes(-5), now.AddMinutes(1));
|
||||||
|
Assert.Equal(4, statements.Single(s => s.QueryKey == "0x1234ABCD").Samples);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{database}];");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue