Skip to content

Commit c0505ee

Browse files
committed
The statement dispatch loop now guarantees per-iteration cursor progress, sys.database_query_store_options returns zero rows for system databases and one Query-Store-OFF row for user databases, sys.query_store_runtime_stats is always empty, OBJECT_ID now resolves catalog views via deterministic object ids.
1 parent 41acc43 commit c0505ee

8 files changed

Lines changed: 335 additions & 3 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using Microsoft.Data.SqlClient;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// The SSMS Query Store probe batch driven over the loopback wire through a
8+
/// real SqlClient connection. It gates on
9+
/// <c>OBJECT_ID(N'[sys].[database_query_store_options]')</c> resolving, reads
10+
/// <c>actual_state</c> (0, Query Store off), then falls to the ELSE of an
11+
/// IF EXISTS over the always-empty <c>sys.query_store_runtime_stats</c>. Two
12+
/// result sets, 0 then 0 — probe-confirmed against a QS-off user database on
13+
/// real SQL Server (2026-07-15).
14+
/// </summary>
15+
[TestClass]
16+
public sealed class QueryStoreProbeWireTests
17+
{
18+
public TestContext TestContext { get; set; } = null!;
19+
20+
[TestMethod]
21+
public async Task SsmsQueryStoreProbeBatch_OverWire_ReturnsZeroThenZero()
22+
{
23+
var simulation = new Simulation();
24+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
25+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
26+
await using var command = new SqlCommand(
27+
"IF OBJECT_ID (N'[sys].[database_query_store_options]') IS NOT NULL " +
28+
"BEGIN " +
29+
"SELECT ISNULL(actual_state, -2) FROM sys.database_query_store_options; " +
30+
"IF EXISTS (SELECT TOP(1) 1 FROM sys.query_store_runtime_stats) SELECT 1 ELSE SELECT 0; " +
31+
"END",
32+
connection);
33+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
34+
35+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
36+
AreEqual(0, Convert.ToInt32(reader.GetValue(0)));
37+
IsFalse(await reader.ReadAsync(TestContext.CancellationToken));
38+
39+
IsTrue(await reader.NextResultAsync(TestContext.CancellationToken));
40+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
41+
AreEqual(0, Convert.ToInt32(reader.GetValue(0)));
42+
IsFalse(await reader.ReadAsync(TestContext.CancellationToken));
43+
44+
IsFalse(await reader.NextResultAsync(TestContext.CancellationToken));
45+
}
46+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Tests for the Query Store catalog views —
7+
/// <c>sys.database_query_store_options</c> (per-database: one OFF row for a
8+
/// user database, zero rows for a system database) and
9+
/// <c>sys.query_store_runtime_stats</c> (always empty) — plus the SSMS Query
10+
/// Store probe batch that reads them and the dispatch-loop non-progress guard
11+
/// that batch's failure mode exposed. Probed against SQL Server 2025
12+
/// (2026-07-15).
13+
/// </summary>
14+
[TestClass]
15+
public sealed class QueryStoreCatalogViewTests
16+
{
17+
/// <summary>
18+
/// The exact SSMS Query Store probe batch: OBJECT_ID gates the block,
19+
/// the first SELECT reads actual_state (0, Query Store off), the nested
20+
/// IF EXISTS over the always-empty runtime-stats view falls to its ELSE.
21+
/// Two result sets, 0 then 0 — probe-confirmed against a QS-off user
22+
/// database on real SQL Server.
23+
/// </summary>
24+
[TestMethod]
25+
[Timeout(60000)]
26+
public void SsmsQueryStoreProbeBatch_ReturnsZeroThenZero()
27+
{
28+
using var reader = new Simulation().ExecuteReader(
29+
"IF OBJECT_ID (N'[sys].[database_query_store_options]') IS NOT NULL " +
30+
"BEGIN " +
31+
"SELECT ISNULL(actual_state, -2) FROM sys.database_query_store_options; " +
32+
"IF EXISTS (SELECT TOP(1) 1 FROM sys.query_store_runtime_stats) SELECT 1 ELSE SELECT 0; " +
33+
"END");
34+
35+
IsTrue(reader.Read());
36+
AreEqual(0, Convert.ToInt32(reader.GetValue(0)));
37+
IsFalse(reader.Read());
38+
39+
IsTrue(reader.NextResult());
40+
IsTrue(reader.Read());
41+
AreEqual(0, Convert.ToInt32(reader.GetValue(0)));
42+
IsFalse(reader.Read());
43+
44+
IsFalse(reader.NextResult());
45+
}
46+
47+
[TestMethod]
48+
public void DatabaseQueryStoreOptions_ObjectId_ResolvesToInt()
49+
=> IsInstanceOfType<int>(new Simulation().ExecuteScalar(
50+
"SELECT OBJECT_ID(N'[sys].[database_query_store_options]')"));
51+
52+
[TestMethod]
53+
public void DatabaseQueryStoreOptions_UserDatabase_ReturnsSingleOffRow()
54+
{
55+
using var reader = new Simulation().ExecuteReader(
56+
"SELECT actual_state, desired_state_desc, query_capture_mode_desc FROM sys.database_query_store_options");
57+
58+
IsTrue(reader.Read());
59+
AreEqual(0, Convert.ToInt32(reader.GetValue(0)));
60+
AreEqual("OFF", reader.GetString(1));
61+
AreEqual("CUSTOM", reader.GetString(2));
62+
IsFalse(reader.Read());
63+
}
64+
65+
[TestMethod]
66+
public void DatabaseQueryStoreOptions_SystemDatabase_ReturnsNoRows()
67+
{
68+
using var reader = new Simulation().ExecuteReader(
69+
"SELECT actual_state FROM master.sys.database_query_store_options");
70+
71+
IsFalse(reader.Read());
72+
}
73+
74+
[TestMethod]
75+
public void QueryStoreRuntimeStats_IsAlwaysEmpty()
76+
=> AreEqual(0, new Simulation().ExecuteScalar("SELECT COUNT(*) FROM sys.query_store_runtime_stats"));
77+
78+
/// <summary>
79+
/// Non-progress-guard regression. A TRY-caught Msg 208 puts the batch in
80+
/// skip mode; the skipped IF's deferred-name recovery abandons the IF
81+
/// mid-parse, orphaning <c>SELECT 1 ELSE SELECT 2</c>; the bare ELSE then
82+
/// raises with the cursor already on a statement boundary. Pre-guard the
83+
/// recovery scan advanced zero tokens and the dispatch loop never
84+
/// terminated (the SSMS Query Store probe crash of 2026-07-15). The guard
85+
/// bounds it: the batch completes promptly and the CATCH returns the first
86+
/// error, Msg 208.
87+
/// </summary>
88+
[TestMethod]
89+
[Timeout(60000)]
90+
public void SkipModeOrphanedElse_DoesNotHang_CatchReturnsFirstError()
91+
=> AreEqual(208, new Simulation().ExecuteScalar(
92+
"BEGIN TRY " +
93+
"SELECT * FROM nosuchtable1 " +
94+
"IF EXISTS (SELECT 1 FROM nosuchtable2) SELECT 1 ELSE SELECT 2 " +
95+
"END TRY BEGIN CATCH SELECT ERROR_NUMBER() END CATCH"));
96+
}

SqlServerSimulator/BuiltInResources.cs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1475,9 +1475,109 @@ void Iso(string name, HeapColumn[] columns, Func<Parser.BatchContext, Database,
14751475
new("credential_id", SqlType.Int32, null, true),
14761476
], EnumerateSysMasterFiles);
14771477

1478+
// sys.database_query_store_options: per-database view (join key is the
1479+
// current database context, not a database_id column). Query Store is
1480+
// never enabled in the simulator, so a user database returns exactly
1481+
// one OFF row and a system database (master/tempdb/model/msdb) returns
1482+
// zero rows — the exact split a live SQL Server 2025 returns
1483+
// (probe-confirmed 2026-07-15). SSMS's Query Store probe gates on
1484+
// OBJECT_ID(N'[sys].[database_query_store_options]') resolving and then
1485+
// reads actual_state. nvarchar(60) _desc columns carry the probed
1486+
// max_length=120 bytes; actual_state_additional_info is nvarchar(4000)
1487+
// (probed max_length=8000 bytes), surfaced as the empty string.
1488+
Sys("database_query_store_options",
1489+
[
1490+
new("desired_state", SqlType.SmallInt, null, false),
1491+
new("desired_state_desc", nvarchar60Catalog, 60, true),
1492+
new("actual_state", SqlType.SmallInt, null, false),
1493+
new("actual_state_desc", nvarchar60Catalog, 60, true),
1494+
new("readonly_reason", SqlType.Int32, null, true),
1495+
new("current_storage_size_mb", SqlType.BigInt, null, true),
1496+
new("flush_interval_seconds", SqlType.BigInt, null, true),
1497+
new("interval_length_minutes", SqlType.BigInt, null, true),
1498+
new("max_storage_size_mb", SqlType.BigInt, null, true),
1499+
new("stale_query_threshold_days", SqlType.BigInt, null, true),
1500+
new("max_plans_per_query", SqlType.BigInt, null, true),
1501+
new("query_capture_mode", SqlType.SmallInt, null, false),
1502+
new("query_capture_mode_desc", nvarchar60Catalog, 60, true),
1503+
new("capture_policy_execution_count", SqlType.Int32, null, true),
1504+
new("capture_policy_total_compile_cpu_time_ms", SqlType.BigInt, null, true),
1505+
new("capture_policy_total_execution_cpu_time_ms", SqlType.BigInt, null, true),
1506+
new("capture_policy_stale_threshold_hours", SqlType.Int32, null, true),
1507+
new("size_based_cleanup_mode", SqlType.SmallInt, null, false),
1508+
new("size_based_cleanup_mode_desc", nvarchar60Catalog, 60, true),
1509+
new("wait_stats_capture_mode", SqlType.SmallInt, null, false),
1510+
new("wait_stats_capture_mode_desc", nvarchar60Catalog, 60, true),
1511+
new("actual_state_additional_info", SqlType.NVarchar, 4000, true),
1512+
], EnumerateSysDatabaseQueryStoreOptions);
1513+
1514+
// sys.query_store_runtime_stats: per-plan runtime-statistics capture.
1515+
// The simulator never runs Query Store, so no runtime stats are ever
1516+
// captured and the view is always empty. SSMS's Query Store probe does
1517+
// IF EXISTS (SELECT TOP(1) 1 FROM sys.query_store_runtime_stats), which
1518+
// must resolve and return zero rows. Column shape probe-confirmed
1519+
// against SQL Server 2025 (2026-07-15): the first nine metric groups
1520+
// carry NOT NULL last/min/max columns, the last four NULL.
1521+
Sys("query_store_runtime_stats",
1522+
BuildQueryStoreRuntimeStatsColumns(nvarchar60Catalog),
1523+
static (_, _) => EmptyCatalogRows);
1524+
14781525
return views;
14791526
}
14801527

1528+
/// <summary>
1529+
/// Column shape for <c>sys.query_store_runtime_stats</c>. The nine
1530+
/// "core" metrics (duration, cpu_time, logical/physical IO, clr_time,
1531+
/// dop, query_max_used_memory, rowcount) expose NOT NULL last/min/max
1532+
/// columns; the four "extended" metrics (num_physical_io_reads,
1533+
/// log_bytes_used, tempdb_space_used, page_server_io_reads) expose them
1534+
/// NULL — matching the probed SQL Server 2025 catalog shape. The view is
1535+
/// always empty, so the column set only ever backs metadata reads.
1536+
/// </summary>
1537+
private static HeapColumn[] BuildQueryStoreRuntimeStatsColumns(NVarcharSqlType nvarchar60Catalog)
1538+
{
1539+
var columns = new List<HeapColumn>
1540+
{
1541+
new("runtime_stats_id", SqlType.BigInt, null, false),
1542+
new("plan_id", SqlType.BigInt, null, false),
1543+
new("runtime_stats_interval_id", SqlType.BigInt, null, false),
1544+
new("execution_type", SqlType.TinyInt, null, false),
1545+
new("execution_type_desc", nvarchar60Catalog, 60, true),
1546+
new("first_execution_time", SqlType.GetDateTimeOffset(7), null, false),
1547+
new("last_execution_time", SqlType.GetDateTimeOffset(7), null, false),
1548+
new("count_executions", SqlType.BigInt, null, false),
1549+
};
1550+
1551+
void Metric(string metric, bool aggregatesNullable)
1552+
{
1553+
columns.Add(new("avg_" + metric, SqlType.Float, null, true));
1554+
columns.Add(new("last_" + metric, SqlType.BigInt, null, aggregatesNullable));
1555+
columns.Add(new("min_" + metric, SqlType.BigInt, null, aggregatesNullable));
1556+
columns.Add(new("max_" + metric, SqlType.BigInt, null, aggregatesNullable));
1557+
columns.Add(new("stdev_" + metric, SqlType.Float, null, true));
1558+
}
1559+
1560+
foreach (var metric in new[]
1561+
{
1562+
"duration", "cpu_time", "logical_io_reads", "logical_io_writes",
1563+
"physical_io_reads", "clr_time", "dop", "query_max_used_memory", "rowcount",
1564+
})
1565+
{
1566+
Metric(metric, aggregatesNullable: false);
1567+
}
1568+
1569+
foreach (var metric in new[]
1570+
{
1571+
"num_physical_io_reads", "log_bytes_used", "tempdb_space_used", "page_server_io_reads",
1572+
})
1573+
{
1574+
Metric(metric, aggregatesNullable: true);
1575+
}
1576+
1577+
columns.Add(new("replica_group_id", SqlType.BigInt, null, false));
1578+
return [.. columns];
1579+
}
1580+
14811581
/// <summary>
14821582
/// Shared empty row set for the AlwaysOn Availability-Group catalog views
14831583
/// (<c>sys.availability_replicas</c> / <c>sys.availability_groups</c> /
@@ -3211,6 +3311,49 @@ private static IEnumerable<SqlValue[]> EnumerateSysDatabaseMirroring(Parser.Batc
32113311
}
32123312
}
32133313

3314+
/// <summary>
3315+
/// Rows for <c>sys.database_query_store_options</c> — one OFF row per user
3316+
/// database, zero rows for a system database (master/tempdb/model/msdb per
3317+
/// <see cref="Simulation.SystemDatabaseNames"/>). The simulator never
3318+
/// enables Query Store, so the single row is the fixed "disabled" shape a
3319+
/// live SQL Server 2025 returns for a Query-Store-off user database
3320+
/// (desired/actual OFF, query_capture_mode CUSTOM). The join key is the
3321+
/// database context (<paramref name="database"/>), so a three-part
3322+
/// <c>master.sys.database_query_store_options</c> read returns nothing.
3323+
/// </summary>
3324+
private static IEnumerable<SqlValue[]> EnumerateSysDatabaseQueryStoreOptions(Parser.BatchContext batch, Database database)
3325+
{
3326+
_ = batch;
3327+
if (Simulation.SystemDatabaseNames.Contains(database.Name))
3328+
yield break;
3329+
3330+
var off = SqlValue.FromNVarchar("OFF");
3331+
yield return [
3332+
SqlValue.FromInt16(0), // desired_state
3333+
off, // desired_state_desc
3334+
SqlValue.FromInt16(0), // actual_state
3335+
off, // actual_state_desc
3336+
SqlValue.FromInt32(0), // readonly_reason
3337+
SqlValue.FromInt64(0), // current_storage_size_mb
3338+
SqlValue.FromInt64(900), // flush_interval_seconds
3339+
SqlValue.FromInt64(60), // interval_length_minutes
3340+
SqlValue.FromInt64(1000), // max_storage_size_mb
3341+
SqlValue.FromInt64(30), // stale_query_threshold_days
3342+
SqlValue.FromInt64(200), // max_plans_per_query
3343+
SqlValue.FromInt16(4), // query_capture_mode
3344+
SqlValue.FromNVarchar("CUSTOM"), // query_capture_mode_desc
3345+
SqlValue.FromInt32(30), // capture_policy_execution_count
3346+
SqlValue.FromInt64(1000), // capture_policy_total_compile_cpu_time_ms
3347+
SqlValue.FromInt64(100), // capture_policy_total_execution_cpu_time_ms
3348+
SqlValue.FromInt32(24), // capture_policy_stale_threshold_hours
3349+
SqlValue.FromInt16(0), // size_based_cleanup_mode
3350+
off, // size_based_cleanup_mode_desc
3351+
SqlValue.FromInt16(0), // wait_stats_capture_mode
3352+
off, // wait_stats_capture_mode_desc
3353+
SqlValue.FromNVarchar(string.Empty), // actual_state_additional_info
3354+
];
3355+
}
3356+
32143357
/// <summary>
32153358
/// Rows for <c>sys.master_files</c> — one data file (<c>type</c> 0, ROWS)
32163359
/// and one log file (<c>type</c> 1, LOG) per database, join key

SqlServerSimulator/Parser/Expressions/ObjectId.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ namespace SqlServerSimulator.Parser.Expressions;
1717
/// </summary>
1818
/// <remarks>
1919
/// <para>
20-
/// Type codes today: <c>'U'</c> (user table), <c>'V'</c> (view), <c>'P'</c>
21-
/// (stored procedure), <c>'FN'</c> / <c>'IF'</c> (scalar / inline-TVF
20+
/// Type codes today: <c>'U'</c> (user table), <c>'V'</c> (view, including the
21+
/// registered <c>sys.*</c> / <c>INFORMATION_SCHEMA.*</c> catalog views),
22+
/// <c>'P'</c> (stored procedure), <c>'FN'</c> / <c>'IF'</c> (scalar / inline-TVF
2223
/// functions), <c>'TR'</c> (DML trigger). Other documented codes (<c>'TF'</c>,
2324
/// FK / DEFAULT constraint codes, …) return NULL pending those features.
2425
/// </para>
@@ -106,6 +107,13 @@ _ when BuiltInToken.Equals(typeFilter, "TF") => function is MultiStatementTableV
106107
{
107108
if (runtime.Batch.TryResolveView(parsed, out var view))
108109
return SqlValue.FromInt32(view.ObjectId);
110+
// Registered sys.* / INFORMATION_SCHEMA.* catalog views resolve as
111+
// system views (type 'V'). Their id is process-stable but not
112+
// byte-identical to real SQL Server's fixed system-view ids; the
113+
// load-bearing property is non-NULL — SSMS's Query Store probe
114+
// gates on OBJECT_ID('[sys].[database_query_store_options]').
115+
if (runtime.Batch.TryResolveCatalogView(parsed, out var catalogView, out _))
116+
return SqlValue.FromInt32(catalogView.ObjectId);
109117
if (typeFilter is not null)
110118
return SqlValue.Null(SqlType.Int32);
111119
}

SqlServerSimulator/Schemas/CatalogView.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,27 @@ internal sealed class CatalogView(string name, HeapColumn[] columns, Func<BatchC
3131
{
3232
public readonly string Name = name;
3333

34+
/// <summary>
35+
/// Deterministic, process-stable <c>object_id</c> surfaced by
36+
/// <c>OBJECT_ID('sys.&lt;view&gt;')</c>. Catalog views are registered
37+
/// process-wide (not per-database) so they can't draw from a
38+
/// <see cref="Database"/>'s object-id allocator; instead the id is a
39+
/// 32-bit FNV-1a hash of the leaf name forced negative, keeping it stable
40+
/// across runs and disjoint from the positive ids user objects allocate
41+
/// (from 100). Load-bearing only for OBJECT_ID resolving to non-NULL —
42+
/// SSMS's Query Store probe gates on
43+
/// <c>OBJECT_ID(N'[sys].[database_query_store_options]') IS NOT NULL</c>.
44+
/// Not byte-identical to real SQL Server's small fixed system-view ids.
45+
/// </summary>
46+
public readonly int ObjectId = ComputeObjectId(name);
47+
48+
private static int ComputeObjectId(string leafName)
49+
{
50+
var hash = Simulation.Fnv1a32.Initial;
51+
hash.Mix(leafName);
52+
return (int)(hash.Value | 0x8000_0000);
53+
}
54+
3455
public readonly HeapColumn[] Columns = columns;
3556

3657
/// <summary>

SqlServerSimulator/Simulation/Simulation.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,10 +979,24 @@ internal IEnumerable<SimulatedStatementOutcome> DispatchStatementsUntil(BatchCon
979979
continue;
980980
}
981981

982+
var statementStartIndex = context.Token.StartIndex;
982983
foreach (var outcome in DispatchOneStatement(batch, requireSemicolonBeforeCte))
983984
yield return outcome;
984985
requireSemicolonBeforeCte = true;
985986
batch.HasDispatchedStatement = true;
987+
988+
// Non-progress guard: a statement dispatch that consumed zero
989+
// tokens would re-dispatch the same position forever. Normal
990+
// parses always consume at least the leading token, but the
991+
// error-recovery scans stop at the next statement boundary —
992+
// and when the failing token itself IS a boundary keyword
993+
// (e.g. an orphaned ELSE after deferred-name recovery
994+
// abandoned its IF mid-parse), the scan advances nothing.
995+
// Discovered via SSMS's Query Store probe batch, where the
996+
// wire path's continue-on-error turned this into an infinite
997+
// error stream that exhausted host memory.
998+
if (context.Token is { } afterDispatch && afterDispatch.StartIndex == statementStartIndex)
999+
context.MoveNextOptional();
9861000
}
9871001
}
9881002
finally

0 commit comments

Comments
 (0)