Skip to content

Commit 137905c

Browse files
paulmedynskimdaigle
authored andcommitted
Fix xevent test failures, avoid orphaned sessions (#3775)
1 parent 8b320de commit 137905c

File tree

3 files changed

+439
-104
lines changed

3 files changed

+439
-104
lines changed

src/Microsoft.Data.SqlClient/tests/ManualTests/DataCommon/DataTestUtility.cs

Lines changed: 287 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
using System.Data;
88
using System.Data.SqlTypes;
99
using System.Diagnostics.Tracing;
10-
using System.Globalization;
1110
using System.IO;
1211
using System.Linq;
1312
using System.Net;
@@ -17,13 +16,15 @@
1716
using System.Security;
1817
using System.Security.Principal;
1918
using System.Text;
19+
using System.Text.RegularExpressions;
2020
using System.Threading;
2121
using System.Threading.Tasks;
2222
using Azure.Core;
2323
using Azure.Identity;
2424
using Microsoft.Data.SqlClient.TestUtilities;
2525
using Microsoft.Identity.Client;
2626
using Xunit;
27+
using Xunit.Abstractions;
2728

2829
namespace Microsoft.Data.SqlClient.ManualTesting.Tests
2930
{
@@ -102,7 +103,7 @@ public static bool IsAzureSynapse
102103
{
103104
if (!string.IsNullOrEmpty(TCPConnectionString))
104105
{
105-
s_sqlServerEngineEdition ??= GetSqlServerProperty(TCPConnectionString, "EngineEdition");
106+
s_sqlServerEngineEdition ??= GetSqlServerProperty(TCPConnectionString, ServerProperty.EngineEdition);
106107
}
107108
_ = int.TryParse(s_sqlServerEngineEdition, out int engineEditon);
108109
return engineEditon == 6;
@@ -124,7 +125,7 @@ public static string SQLServerVersion
124125
{
125126
if (!string.IsNullOrEmpty(TCPConnectionString))
126127
{
127-
s_sQLServerVersion ??= GetSqlServerProperty(TCPConnectionString, "ProductMajorVersion");
128+
s_sQLServerVersion ??= GetSqlServerProperty(TCPConnectionString, ServerProperty.ProductMajorVersion);
128129
}
129130
return s_sQLServerVersion;
130131
}
@@ -238,7 +239,9 @@ public static IEnumerable<string> GetConnectionStrings(bool withEnclave)
238239
yield return TCPConnectionString;
239240
}
240241
// Named Pipes are not supported on Unix platform and for Azure DB
241-
if (Environment.OSVersion.Platform != PlatformID.Unix && IsNotAzureServer() && !string.IsNullOrEmpty(NPConnectionString))
242+
if (Environment.OSVersion.Platform != PlatformID.Unix &&
243+
IsNotAzureServer() &&
244+
!string.IsNullOrEmpty(NPConnectionString))
242245
{
243246
yield return NPConnectionString;
244247
}
@@ -287,29 +290,99 @@ private static Task<string> AcquireTokenAsync(string authorityURL, string userID
287290

288291
public static bool IsKerberosTest => !string.IsNullOrEmpty(KerberosDomainUser) && !string.IsNullOrEmpty(KerberosDomainPassword);
289292

290-
public static string GetSqlServerProperty(string connectionString, string propertyName)
293+
#nullable enable
294+
295+
/// <summary>
296+
/// Returns the current test name as:
297+
///
298+
/// ClassName.MethodName
299+
///
300+
/// xUnit v2 doesn't provide access to a test context, so we use
301+
/// reflection into the ITestOutputHelper to get the test name.
302+
/// </summary>
303+
///
304+
/// <param name="outputHelper">
305+
/// The output helper instance for the currently running test.
306+
/// </param>
307+
///
308+
/// <returns>The current test name.</returns>
309+
public static string CurrentTestName(ITestOutputHelper outputHelper)
310+
{
311+
// Reflect our way to the ITest instance.
312+
var type = outputHelper.GetType();
313+
Assert.NotNull(type);
314+
var testMember = type.GetField("test", BindingFlags.Instance | BindingFlags.NonPublic);
315+
Assert.NotNull(testMember);
316+
var test = testMember.GetValue(outputHelper) as ITest;
317+
Assert.NotNull(test);
318+
319+
// The DisplayName is in the format:
320+
//
321+
// Namespace.ClassName.MethodName(args)
322+
//
323+
// We only want the ClassName.MethodName portion.
324+
//
325+
Match match = TestNameRegex.Match(test.DisplayName);
326+
Assert.True(match.Success);
327+
// There should be 2 groups: the overall match, and the capture
328+
// group.
329+
Assert.Equal(2, match.Groups.Count);
330+
331+
// The portion we want is in the capture group.
332+
return match.Groups[1].Value;
333+
}
334+
335+
private static readonly Regex TestNameRegex = new(
336+
// Capture the ClassName.MethodName portion, which may terminate
337+
// the name, or have (args...) appended.
338+
@"\.((?:[^.]+)\.(?:[^.\(]+))(?:\(.*\))?$",
339+
RegexOptions.Compiled);
340+
341+
/// <summary>
342+
/// SQL Server properties we can query.
343+
///
344+
/// GOTCHA: The enum member names must match the property names
345+
/// queryable via T-SQL SERVERPROPERTY(). See:
346+
///
347+
/// https://learn.microsoft.com/en-us/sql/t-sql/functions/serverproperty-transact-sql
348+
/// </summary>
349+
public enum ServerProperty
350+
{
351+
ProductMajorVersion,
352+
EngineEdition
353+
}
354+
355+
public static string GetSqlServerProperty(string connectionString, ServerProperty property)
291356
{
292-
string propertyValue = string.Empty;
293357
using SqlConnection conn = new(connectionString);
294358
conn.Open();
295-
SqlCommand command = conn.CreateCommand();
296-
command.CommandText = $"SELECT SERVERProperty('{propertyName}')";
297-
SqlDataReader reader = command.ExecuteReader();
298-
if (reader.Read())
359+
return GetSqlServerProperty(conn, property);
360+
}
361+
362+
public static string GetSqlServerProperty(SqlConnection connection, ServerProperty property)
363+
{
364+
using SqlCommand command = connection.CreateCommand();
365+
command.CommandText = $"SELECT SERVERProperty('{property}')";
366+
using SqlDataReader reader = command.ExecuteReader();
367+
368+
Assert.True(reader.Read());
369+
370+
switch (property)
299371
{
300-
switch (propertyName)
301-
{
302-
case "EngineEdition":
303-
propertyValue = reader.GetInt32(0).ToString();
304-
break;
305-
case "ProductMajorVersion":
306-
propertyValue = reader.GetString(0);
307-
break;
308-
}
372+
case ServerProperty.EngineEdition:
373+
// EngineEdition is returned as an int.
374+
return reader.GetInt32(0).ToString();
375+
case ServerProperty.ProductMajorVersion:
376+
default:
377+
// ProductMajorVersion is returned as a string.
378+
//
379+
// Assume any unknown property is also a string.
380+
return reader.GetString(0);
309381
}
310-
return propertyValue;
311382
}
312383

384+
#nullable disable
385+
313386
public static bool GetSQLServerStatusOnTDS8(string connectionString)
314387
{
315388
bool isTDS8Supported = false;
@@ -1168,8 +1241,200 @@ protected override void OnEventWritten(EventWrittenEventArgs eventData)
11681241
{
11691242
IDs.Add(eventData.EventId);
11701243
EventData.Add(eventData);
1244+
OnMatchingEventWritten(eventData);
11711245
}
11721246
}
1247+
1248+
protected virtual void OnMatchingEventWritten(EventWrittenEventArgs eventData)
1249+
{
1250+
}
1251+
}
1252+
1253+
#nullable enable
1254+
1255+
public class XEventScope : IDisposable
1256+
{
1257+
#region Private Fields
1258+
1259+
// Maximum dispatch latency for XEvents, in seconds.
1260+
private const int MaxDispatchLatencySeconds = 5;
1261+
1262+
// The connection to use for all operations.
1263+
private readonly SqlConnection _connection;
1264+
1265+
// True if connected to an Azure SQL instance.
1266+
private readonly bool _isAzureSql;
1267+
1268+
// True if connected to a non-Azure SQL Server 2025 (version 17) or
1269+
// higher.
1270+
private readonly bool _isVersion17OrHigher;
1271+
1272+
// Duration for the XEvent session, in minutes.
1273+
private readonly ushort _durationInMinutes;
1274+
1275+
#endregion
1276+
1277+
#region Properties
1278+
1279+
/// <summary>
1280+
/// The name of the XEvent session, derived from the session name
1281+
/// provided at construction time, with a unique suffix appended.
1282+
/// </summary>
1283+
public string SessionName { get; }
1284+
1285+
#endregion
1286+
1287+
#region Construction
1288+
1289+
/// <summary>
1290+
/// Construct with the specified parameters.
1291+
///
1292+
/// This will use the connection to query the server properties and
1293+
/// setup and start the XEvent session.
1294+
/// </summary>
1295+
/// <param name="sessionName">The base name of the session.</param>
1296+
/// <param name="connection">The SQL connection to use. (Must already be open.)</param>
1297+
/// <param name="eventSpecification">The event specification T-SQL string.</param>
1298+
/// <param name="targetSpecification">The target specification T-SQL string.</param>
1299+
/// <param name="durationInMinutes">The duration of the session in minutes.</param>
1300+
public XEventScope(
1301+
string sessionName,
1302+
// The connection must already be open.
1303+
SqlConnection connection,
1304+
string eventSpecification,
1305+
string targetSpecification,
1306+
ushort durationInMinutes = 5)
1307+
{
1308+
SessionName = GenerateRandomCharacters(sessionName);
1309+
1310+
_connection = connection;
1311+
Assert.Equal(ConnectionState.Open, _connection.State);
1312+
1313+
_durationInMinutes = durationInMinutes;
1314+
1315+
// EngineEdition 5 indicates Azure SQL.
1316+
_isAzureSql = GetSqlServerProperty(connection, ServerProperty.EngineEdition) == "5";
1317+
1318+
// Determine if we're connected to a SQL Server instance version
1319+
// 17 or higher.
1320+
if (!_isAzureSql)
1321+
{
1322+
int majorVersion;
1323+
Assert.True(
1324+
int.TryParse(
1325+
GetSqlServerProperty(connection, ServerProperty.ProductMajorVersion),
1326+
out majorVersion));
1327+
_isVersion17OrHigher = majorVersion >= 17;
1328+
}
1329+
1330+
// Setup and start the XEvent session.
1331+
string sessionLocation = _isAzureSql ? "DATABASE" : "SERVER";
1332+
1333+
// Both Azure SQL and SQL Server 2025+ support setting a maximum
1334+
// duration for the XEvent session.
1335+
string duration =
1336+
_isAzureSql || _isVersion17OrHigher
1337+
? $"MAX_DURATION={_durationInMinutes} MINUTES,"
1338+
: string.Empty;
1339+
1340+
string xEventCreateAndStartCommandText =
1341+
$@"CREATE EVENT SESSION [{SessionName}] ON {sessionLocation}
1342+
{eventSpecification}
1343+
{targetSpecification}
1344+
WITH (
1345+
{duration}
1346+
MAX_MEMORY=16 MB,
1347+
EVENT_RETENTION_MODE=ALLOW_SINGLE_EVENT_LOSS,
1348+
MAX_DISPATCH_LATENCY={MaxDispatchLatencySeconds} SECONDS,
1349+
MAX_EVENT_SIZE=0 KB,
1350+
MEMORY_PARTITION_MODE=NONE,
1351+
TRACK_CAUSALITY=ON,
1352+
STARTUP_STATE=OFF)
1353+
1354+
ALTER EVENT SESSION [{SessionName}] ON {sessionLocation} STATE = START ";
1355+
1356+
using SqlCommand createXEventSession = new SqlCommand(xEventCreateAndStartCommandText, _connection);
1357+
createXEventSession.ExecuteNonQuery();
1358+
}
1359+
1360+
/// <summary>
1361+
/// Disposal stops and drops the XEvent session.
1362+
/// </summary>
1363+
/// <remarks>
1364+
/// Disposal isn't perfect - tests can abort without cleaning up the
1365+
/// events they have created. For Azure SQL targets that outlive the
1366+
/// test pipelines, it is beneficial to periodically log into the
1367+
/// database and drop old XEvent sessions using T-SQL similar to
1368+
/// this:
1369+
///
1370+
/// DECLARE @sql NVARCHAR(MAX) = N'';
1371+
///
1372+
/// -- Identify inactive (stopped) event sessions and generate DROP commands
1373+
/// SELECT @sql += N'DROP EVENT SESSION [' + name + N'] ON SERVER;' + CHAR(13) + CHAR(10)
1374+
/// FROM sys.server_event_sessions
1375+
/// WHERE running = 0; -- Filter for sessions that are not running (inactive)
1376+
///
1377+
/// -- Print the generated commands for review (optional, but recommended)
1378+
/// PRINT @sql;
1379+
///
1380+
/// -- Execute the generated commands
1381+
/// EXEC sys.sp_executesql @sql;
1382+
/// </remarks>
1383+
public void Dispose()
1384+
{
1385+
string dropXEventSessionCommand = _isAzureSql
1386+
// We choose the sys.(database|server)_event_sessions views
1387+
// here to ensure we find sessions that may not be running.
1388+
? $"IF EXISTS (select * from sys.database_event_sessions where name ='{SessionName}')" +
1389+
$" DROP EVENT SESSION [{SessionName}] ON DATABASE"
1390+
: $"IF EXISTS (select * from sys.server_event_sessions where name ='{SessionName}')" +
1391+
$" DROP EVENT SESSION [{SessionName}] ON SERVER";
1392+
1393+
using SqlCommand command = new SqlCommand(dropXEventSessionCommand, _connection);
1394+
command.ExecuteNonQuery();
1395+
}
1396+
1397+
#endregion
1398+
1399+
#region Public Methods
1400+
1401+
/// <summary>
1402+
/// Query the XEvent session for its collected events, returning
1403+
/// them as an XML document.
1404+
///
1405+
/// This always blocks the thread for MaxDispatchLatencySeconds to
1406+
/// ensure that all events have been flushed into the ring buffer.
1407+
/// </summary>
1408+
public System.Xml.XmlDocument GetEvents()
1409+
{
1410+
string xEventQuery = _isAzureSql
1411+
? $@"SELECT xet.target_data
1412+
FROM sys.dm_xe_database_session_targets AS xet
1413+
INNER JOIN sys.dm_xe_database_sessions AS xe
1414+
ON (xe.address = xet.event_session_address)
1415+
WHERE xe.name = '{SessionName}'"
1416+
: $@"SELECT xet.target_data
1417+
FROM sys.dm_xe_session_targets AS xet
1418+
INNER JOIN sys.dm_xe_sessions AS xe
1419+
ON (xe.address = xet.event_session_address)
1420+
WHERE xe.name = '{SessionName}'";
1421+
1422+
using SqlCommand command = new SqlCommand(xEventQuery, _connection);
1423+
1424+
// Wait for maximum dispatch latency to ensure all events
1425+
// have been flushed to the ring buffer.
1426+
Thread.Sleep(MaxDispatchLatencySeconds * 1000);
1427+
1428+
string? targetData = command.ExecuteScalar() as string;
1429+
Assert.NotNull(targetData);
1430+
1431+
System.Xml.XmlDocument xmlDocument = new System.Xml.XmlDocument();
1432+
1433+
xmlDocument.LoadXml(targetData);
1434+
return xmlDocument;
1435+
}
1436+
1437+
#endregion
11731438
}
11741439

11751440
/// <summary>
@@ -1198,4 +1463,6 @@ public static string GetMachineFQDN(string hostname)
11981463
return fqdn.ToString();
11991464
}
12001465
}
1466+
1467+
#nullable disable
12011468
}

0 commit comments

Comments
 (0)