diff --git a/src/ServiceControl.Transports.SqlServer.Tests/QueueLengthQueryTests.cs b/src/ServiceControl.Transports.SqlServer.Tests/QueueLengthQueryTests.cs new file mode 100644 index 0000000000..b274eab39a --- /dev/null +++ b/src/ServiceControl.Transports.SqlServer.Tests/QueueLengthQueryTests.cs @@ -0,0 +1,164 @@ +namespace ServiceControl.Transport.Tests; + +using System; +using System.Linq; +using NUnit.Framework; +using Transports.SqlServer; + +[TestFixture] +class QueueLengthQueryTests +{ + [Test] + public void BuildBulkLengthQuery_emits_a_single_catalog_view_query_for_all_tables() + { + var tables = new[] + { + SqlTable.Parse("Sales", "dbo"), + SqlTable.Parse("Billing", "dbo"), + }; + + var query = SqlTable.BuildBulkLengthQuery(catalog: null, tables); + + // One statement, read from the catalog views — no per-table IF EXISTS, no scan of the queue tables. + Assert.That(query, Does.Contain("sys.partitions")); + Assert.That(query, Does.Contain("p.index_id IN (0, 1)")); + Assert.That(query, Does.Not.Contain("INFORMATION_SCHEMA")); + Assert.That(query, Does.Not.Contain("RowVersion")); + + // Dirty reads are scoped to this statement via NOLOCK hints, not a connection-scoped SET that + // would leak READ UNCOMMITTED onto the pooled connection. + Assert.That(query, Does.Contain("WITH (NOLOCK)")); + Assert.That(query, Does.Not.Contain("ISOLATION LEVEL")); + + // Both tracked tables are covered by the single query. + Assert.That(query, Does.Contain("t.name = 'Sales'")); + Assert.That(query, Does.Contain("t.name = 'Billing'")); + + // Exactly one result-producing statement. + Assert.That(System.Text.RegularExpressions.Regex.Matches(query, "SELECT").Count, Is.EqualTo(1)); + } + + [Test] + public void BuildBulkLengthQuery_with_no_tables_stays_valid_and_returns_no_rows() + { + var query = SqlTable.BuildBulkLengthQuery(catalog: null, []); + + // No predicate terms must not produce the invalid `AND ()`; a constant-false predicate keeps the + // query syntactically valid while matching nothing. + Assert.That(query, Does.Not.Contain("AND ()")); + Assert.That(query, Does.Contain("1 = 0")); + } + + [Test] + public void BuildBulkLengthQuery_prefixes_the_catalog_when_supplied() + { + var tables = new[] { SqlTable.Parse("Sales@dbo@MyCatalog", "dbo") }; + + var query = SqlTable.BuildBulkLengthQuery(catalog: "MyCatalog", tables); + + Assert.That(query, Does.Contain("[MyCatalog].sys.partitions")); + Assert.That(query, Does.Contain("[MyCatalog].sys.tables")); + Assert.That(query, Does.Contain("[MyCatalog].sys.schemas")); + } + + [Test] + public void RemoveQueueLengthQueryDelayInterval_parses_and_strips_the_custom_part() + { + const string connectionString = "Data Source=.;Initial Catalog=nsb;Integrated Security=true;QueueLengthQueryDelayInterval=1000"; + + var cleaned = connectionString.RemoveQueueLengthQueryDelayInterval(out var interval); + + Assert.That(interval, Is.EqualTo(TimeSpan.FromSeconds(1))); + // The custom key must be stripped so SqlConnection never sees the unknown keyword. + Assert.That(cleaned, Does.Not.Contain("QueueLengthQueryDelayInterval").IgnoreCase); + Assert.That(cleaned, Does.Contain("Initial Catalog=nsb").IgnoreCase); + } + + [Test] + public void RemoveQueueLengthQueryDelayInterval_defaults_to_null_when_absent() + { + const string connectionString = "Data Source=.;Initial Catalog=nsb;Integrated Security=true"; + + connectionString.RemoveQueueLengthQueryDelayInterval(out var interval); + + Assert.That(interval, Is.Null); + } + + [Test] + public void RemoveQueueLengthQueryDelayInterval_throws_on_non_numeric_value() + { + const string connectionString = "Data Source=.;Initial Catalog=nsb;QueueLengthQueryDelayInterval=soon"; + + Assert.That(() => connectionString.RemoveQueueLengthQueryDelayInterval(out _), + Throws.Exception.With.Message.Contains("QueueLengthQueryDelayInterval")); + } + + [Test] + public void RemoveQueueLengthQueryDelayInterval_throws_on_negative_value() + { + const string connectionString = "Data Source=.;Initial Catalog=nsb;QueueLengthQueryDelayInterval=-5"; + + // A negative interval would flow through to Task.Delay and throw mid-loop; reject it up front. + Assert.That(() => connectionString.RemoveQueueLengthQueryDelayInterval(out _), + Throws.Exception.With.Message.Contains("QueueLengthQueryDelayInterval")); + } + + [Test] + public void RemoveQueueLengthQueryDelayInterval_throws_on_zero_value() + { + const string connectionString = "Data Source=.;Initial Catalog=nsb;QueueLengthQueryDelayInterval=0"; + + // Zero would poll with no pacing at all; reject it. + Assert.That(() => connectionString.RemoveQueueLengthQueryDelayInterval(out _), + Throws.Exception.With.Message.Contains("QueueLengthQueryDelayInterval")); + } + + [Test] + public void RemoveQueueLengthQueryMaxDelayInterval_parses_and_strips_the_custom_part() + { + const string connectionString = "Data Source=.;Initial Catalog=nsb;QueueLengthQueryMaxDelayInterval=60000"; + + var cleaned = connectionString.RemoveQueueLengthQueryMaxDelayInterval(out var interval); + + Assert.That(interval, Is.EqualTo(TimeSpan.FromSeconds(60))); + Assert.That(cleaned, Does.Not.Contain("QueueLengthQueryMaxDelayInterval").IgnoreCase); + } + + static readonly TimeSpan Base = TimeSpan.FromMilliseconds(200); + static readonly TimeSpan Max = TimeSpan.FromSeconds(60); + + [Test] + public void NextDelay_snaps_back_to_base_when_any_queue_has_work() + { + // Even fully backed off, a single non-empty queue returns to full speed immediately. + Assert.That(QueueLengthProvider.NextDelay(Max, Base, Max, maxObservedLength: 1), Is.EqualTo(Base)); + } + + [Test] + public void NextDelay_doubles_while_idle() + { + Assert.That(QueueLengthProvider.NextDelay(Base, Base, Max, maxObservedLength: 0), + Is.EqualTo(TimeSpan.FromMilliseconds(400))); + } + + [Test] + public void NextDelay_caps_at_max_while_idle() + { + Assert.That(QueueLengthProvider.NextDelay(TimeSpan.FromSeconds(40), Base, Max, maxObservedLength: 0), + Is.EqualTo(Max)); + } + + [Test] + public void NextDelay_never_drops_below_base() + { + Assert.That(QueueLengthProvider.NextDelay(TimeSpan.FromMilliseconds(50), Base, Max, maxObservedLength: 0), + Is.EqualTo(Base)); + } + + [Test] + public void NextDelay_with_max_equal_to_base_disables_backoff() + { + // Operator did not opt in (max == base) -> cadence is constant at base, even while idle. + Assert.That(QueueLengthProvider.NextDelay(Base, Base, Base, maxObservedLength: 0), Is.EqualTo(Base)); + } +} diff --git a/src/ServiceControl.Transports.SqlServer/ConnectionStringExtensions.cs b/src/ServiceControl.Transports.SqlServer/ConnectionStringExtensions.cs index 277e82596f..b96b85aa59 100644 --- a/src/ServiceControl.Transports.SqlServer/ConnectionStringExtensions.cs +++ b/src/ServiceControl.Transports.SqlServer/ConnectionStringExtensions.cs @@ -1,5 +1,6 @@ namespace ServiceControl.Transports.SqlServer { + using System; using System.Data.Common; static class ConnectionStringExtensions @@ -11,6 +12,39 @@ public static string RemoveCustomConnectionStringParts(this string connectionStr .RemoveCustomConnectionStringPart(subscriptionsTableName, out subscriptionTable); } + // Extracts the optional, ServiceControl-specific 'QueueLengthQueryDelayInterval' (milliseconds) from + // the connection string and removes it so it is never handed to SqlConnection (which would reject the + // unknown keyword). Mirrors the existing convention used by the Azure Service Bus transport. + // This is the BASE interval used while any monitored queue has messages. + public static string RemoveQueueLengthQueryDelayInterval(this string connectionString, out TimeSpan? interval) => + connectionString.RemoveIntervalMilliseconds(queueLengthQueryDelayInterval, out interval); + + // Extracts the optional 'QueueLengthQueryMaxDelayInterval' (milliseconds) — the upper bound the adaptive + // back-off ramps to while every monitored queue is empty. When omitted a default ceiling applies; set it + // equal to the base interval to disable back-off. + public static string RemoveQueueLengthQueryMaxDelayInterval(this string connectionString, out TimeSpan? interval) => + connectionString.RemoveIntervalMilliseconds(queueLengthQueryMaxDelayInterval, out interval); + + static string RemoveIntervalMilliseconds(this string connectionString, string key, out TimeSpan? interval) + { + interval = null; + + var builder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + + if (builder.TryGetValue(key, out var value)) + { + if (!int.TryParse(value.ToString(), out var milliseconds) || milliseconds <= 0) + { + throw new Exception($"Can't parse '{value}' as a valid {key} (expected a positive integer number of milliseconds)."); + } + + interval = TimeSpan.FromMilliseconds(milliseconds); + builder.Remove(key); + } + + return builder.ConnectionString; + } + public static string RemoveCustomConnectionStringPart(this string connectionString, string partName, out string schema) { var builder = new DbConnectionStringBuilder @@ -30,5 +64,7 @@ public static string RemoveCustomConnectionStringPart(this string connectionStri const string queueSchemaName = "Queue Schema"; const string subscriptionsTableName = "Subscriptions Table"; + const string queueLengthQueryDelayInterval = "QueueLengthQueryDelayInterval"; + const string queueLengthQueryMaxDelayInterval = "QueueLengthQueryMaxDelayInterval"; } } \ No newline at end of file diff --git a/src/ServiceControl.Transports.SqlServer/QueueLengthProvider.cs b/src/ServiceControl.Transports.SqlServer/QueueLengthProvider.cs index e89fe36d25..b2cdfd653f 100644 --- a/src/ServiceControl.Transports.SqlServer/QueueLengthProvider.cs +++ b/src/ServiceControl.Transports.SqlServer/QueueLengthProvider.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; + using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -14,10 +15,26 @@ class QueueLengthProvider : AbstractQueueLengthProvider public QueueLengthProvider(TransportSettings settings, Action store, ILogger logger) : base(settings, store) { connectionString = ConnectionString - .RemoveCustomConnectionStringParts(out var customSchema, out _); + .RemoveCustomConnectionStringParts(out var customSchema, out _) + .RemoveQueueLengthQueryDelayInterval(out var configuredInterval) + .RemoveQueueLengthQueryMaxDelayInterval(out var configuredMaxInterval); + + baseDelay = configuredInterval ?? DefaultQueryDelayInterval; + // Adaptive back-off is ON by default: while every monitored queue is idle the cadence ramps from + // the base interval up to this ceiling. An operator can widen or effectively disable it (set equal + // to the base) via QueueLengthQueryMaxDelayInterval. Never let it fall below the base. + maxDelay = configuredMaxInterval ?? DefaultQueryMaxDelayInterval; + if (maxDelay < baseDelay) + { + maxDelay = baseDelay; + } + currentDelay = baseDelay; defaultSchema = customSchema ?? "dbo"; this.logger = logger; + + logger.LogInformation("SQL queue length query interval: base {BaseDelay}, max {MaxDelay} (adaptive back-off {State})", + baseDelay, maxDelay, maxDelay > baseDelay ? "enabled" : "disabled"); } public override void TrackEndpointInputQueue(EndpointToQueueMapping queueToTrack) { @@ -40,23 +57,71 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { + // Time the whole iteration so the pacing delay below can subtract the work already done this + // cycle. The effective cadence is then max(currentDelay, iterationDuration) — the query + // overlaps the wait instead of being added to it. That additive query-time term is what let + // the cadence drift past the 1s monitoring bucket and starve buckets of samples, producing the + // #4556 false-zero "sawtooth"; a slow query (> interval) simply paces at its own duration. + var iterationStart = Stopwatch.GetTimestamp(); + try { - await Task.Delay(QueryDelayInterval, stoppingToken); - await QueryTableSizes(stoppingToken); UpdateQueueLengthStore(); + + // Adapt the cadence: full speed while any queue has work, exponential back-off while the + // whole system is idle. Backing off only when EVERY queue is empty keeps the fix for + // issue #4556 intact — the false-zero "sawtooth" only affects non-empty queues, and those + // are always sampled at the base interval here. Computed after the query (not before) so a + // snap-back to the base interval takes effect on THIS iteration's pacing rather than one + // backed-off wait later. + var maxObservedLength = tableSizes.IsEmpty ? 0 : tableSizes.Values.Max(); + currentDelay = NextDelay(currentDelay, baseDelay, maxDelay, maxObservedLength); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { - // no-op + break; } catch (Exception e) { logger.LogError(e, "Error querying sql queue sizes"); } + + // Pace AFTER the try so a failed query is still throttled by the interval. Otherwise a fast, + // persistent failure (bad login, denied SELECT) would spin the loop with no delay, hammering + // the server and flooding the log. currentDelay is unchanged on the failure path, so the last + // good cadence is reused. + var remaining = currentDelay - Stopwatch.GetElapsedTime(iterationStart); + if (remaining > TimeSpan.Zero) + { + try + { + await Task.Delay(remaining, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + } + } + } + + // Pure cadence policy (no I/O) so it can be unit tested without a database or the polling loop. + internal static TimeSpan NextDelay(TimeSpan current, TimeSpan baseDelay, TimeSpan maxDelay, long maxObservedLength) + { + if (maxObservedLength > 0) + { + return baseDelay; // work present -> snap back to full speed + } + + var doubled = TimeSpan.FromTicks(current.Ticks * 2); + if (doubled < baseDelay) + { + doubled = baseDelay; } + + return doubled > maxDelay ? maxDelay : doubled; } void UpdateQueueLengthStore() @@ -79,60 +144,91 @@ void UpdateQueueLengthStore() async Task QueryTableSizes(CancellationToken cancellationToken) { - var chunks = tableSizes - .Select((i, index) => new - { - i, - index - }) - .GroupBy(p => p.index / QueryChunkSize) - .Select(grp => grp.Select(g => g.i).ToArray()) - .ToList(); + // sys.partitions is per-database, so group the tracked tables by catalog and issue one + // bulk query per distinct catalog. In the common single-catalog setup this is ONE query + // per poll for the whole system, regardless of how many endpoints are monitored. + var byCatalog = tableSizes.Keys.GroupBy(t => t.UnquotedCatalog); await using var connection = new SqlConnection(connectionString); await connection.OpenAsync(cancellationToken); - foreach (var chunk in chunks) + foreach (var catalogGroup in byCatalog) { - await UpdateChunk(connection, chunk, cancellationToken); + await QueryCatalog(connection, catalogGroup.Key, catalogGroup.ToArray(), cancellationToken); } } - async Task UpdateChunk(SqlConnection connection, KeyValuePair[] chunk, CancellationToken cancellationToken) + async Task QueryCatalog(SqlConnection connection, string catalog, SqlTable[] tables, CancellationToken cancellationToken) { - var query = string.Join(Environment.NewLine, chunk.Select(c => c.Key.LengthQuery)); + var query = SqlTable.BuildBulkLengthQuery(catalog, tables); - await using var command = new SqlCommand(query, connection); - await using var reader = await command.ExecuteReaderAsync(cancellationToken); - foreach (var chunkPair in chunk) + // (schema, name) -> length, for matching the result rows back to the tracked tables. + var results = new Dictionary<(string, string), int>(SchemaNameComparer); + + await using (var command = new SqlCommand(query, connection)) + await using (var reader = await command.ExecuteReaderAsync(cancellationToken)) { - await reader.ReadAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var schema = reader.GetString(0); + var name = reader.GetString(1); + var length = reader.GetInt64(2); // SUM(p.rows) is bigint - var queueLength = reader.GetInt32(0); + results[(schema, name)] = length > int.MaxValue ? int.MaxValue : (int)length; + } + } - if (queueLength == -1) + foreach (var table in tables) + { + if (results.TryGetValue((table.UnquotedSchema, table.UnquotedName), out var length)) { - logger.LogWarning("Table {TableName} does not exist", chunkPair.Key); + // Indexer rather than TryUpdate: record the freshly read length unconditionally. A + // concurrent TrackEndpointInputQueue that removed this key could be resurrected here, but + // there is no untrack path today so the entry would persist regardless — kept simple. + tableSizes[table] = length; } else { - tableSizes.TryUpdate(chunkPair.Key, queueLength, chunkPair.Value); + // No catalog row for this table -> the queue table does not (yet) exist. Record 0 rather + // than leaving a stale value: maxObservedLength (the back-off trigger) is a Max over + // tableSizes, so a lingering non-zero here would pin the cadence at the base interval and + // permanently defeat the adaptive back-off once a tracked queue is dropped. + tableSizes[table] = 0; + logger.LogWarning("Table {TableName} does not exist", table); } - - await reader.NextResultAsync(cancellationToken); } } + static readonly IEqualityComparer<(string, string)> SchemaNameComparer = + new SchemaNameEqualityComparer(); + + sealed class SchemaNameEqualityComparer : IEqualityComparer<(string, string)> + { + public bool Equals((string, string) x, (string, string) y) => + StringComparer.OrdinalIgnoreCase.Equals(x.Item1, y.Item1) && + StringComparer.OrdinalIgnoreCase.Equals(x.Item2, y.Item2); + + public int GetHashCode((string, string) obj) => + HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Item1), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Item2)); + } + readonly ConcurrentDictionary tableNames = new ConcurrentDictionary(); readonly ConcurrentDictionary tableSizes = new ConcurrentDictionary(); readonly string connectionString; readonly string defaultSchema; + readonly TimeSpan baseDelay; + readonly TimeSpan maxDelay; + TimeSpan currentDelay; readonly ILogger logger; - static readonly TimeSpan QueryDelayInterval = TimeSpan.FromMilliseconds(200); - - const int QueryChunkSize = 10; + // Base interval matches the finest monitoring bucket (1-minute history / 60 = 1s per point). With the + // concurrent pacing above this yields ~one sample per bucket, so the old 200ms oversampling workaround + // for #4556 is no longer needed. + static readonly TimeSpan DefaultQueryDelayInterval = TimeSpan.FromSeconds(1); + static readonly TimeSpan DefaultQueryMaxDelayInterval = TimeSpan.FromSeconds(10); } } diff --git a/src/ServiceControl.Transports.SqlServer/ServiceControl.Transports.SqlServer.csproj b/src/ServiceControl.Transports.SqlServer/ServiceControl.Transports.SqlServer.csproj index fb0fd8ff89..9065f6306e 100644 --- a/src/ServiceControl.Transports.SqlServer/ServiceControl.Transports.SqlServer.csproj +++ b/src/ServiceControl.Transports.SqlServer/ServiceControl.Transports.SqlServer.csproj @@ -14,6 +14,10 @@ + + + + diff --git a/src/ServiceControl.Transports.SqlServer/SqlTable.cs b/src/ServiceControl.Transports.SqlServer/SqlTable.cs index 0e629b1762..70cc88dd2e 100644 --- a/src/ServiceControl.Transports.SqlServer/SqlTable.cs +++ b/src/ServiceControl.Transports.SqlServer/SqlTable.cs @@ -1,52 +1,71 @@ #nullable enable namespace ServiceControl.Transports.SqlServer { + using System.Collections.Generic; + using System.Linq; - class SqlTable + class SqlTable(string name, string schema, string? catalog) { - SqlTable(string name, string schema, string? catalog) - { - var unquotedSchema = NameHelper.Unquote(schema); - var unquotedName = NameHelper.Unquote(name); - var quotedName = NameHelper.Quote(name); - var quotedSchema = NameHelper.Quote(schema); - //HINT: The query approximates queue length value based on max and min - // of RowVersion IDENTITY(1,1) column. There are couple of scenarios - // that might lead to the approximation being off. More details here: - // https://docs.microsoft.com/en-us/sql/t-sql/statements/create-table-transact-sql-identity-property?view=sql-server-ver15#remarks - // - // Min and Max values return NULL when no rows are found. - if (catalog == null) - { - _fullTableName = $"{quotedSchema}.{quotedName}"; - - LengthQuery = $""" - IF (EXISTS (SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = '{unquotedSchema}' AND TABLE_NAME = '{unquotedName}')) - SELECT isnull(cast(max([RowVersion]) - min([RowVersion]) + 1 AS int), 0) FROM {_fullTableName} WITH (nolock) - ELSE - SELECT -1; - """; - } - else - { - var quotedCatalog = NameHelper.Quote(catalog); - _fullTableName = $"{quotedCatalog}.{quotedSchema}.{quotedName}"; - - LengthQuery = $""" - IF (EXISTS (SELECT TABLE_NAME FROM {quotedCatalog}.INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = '{unquotedSchema}' AND TABLE_NAME = '{unquotedName}')) - SELECT isnull(cast(max([RowVersion]) - min([RowVersion]) + 1 AS int), 0) FROM {_fullTableName} WITH (nolock) - ELSE - SELECT -1; - """; - } - } + // Unquoted identifier parts, exposed so the bulk catalog-view query (see QueueLengthProvider) + // can group tables by catalog and match rows from sys.schemas / sys.tables back to the + // tracked tables without parsing the composed full name. + public string UnquotedName { get; } = NameHelper.Unquote(name); + public string UnquotedSchema { get; } = NameHelper.Unquote(schema); + public string? UnquotedCatalog { get; } = catalog == null ? null : NameHelper.Unquote(catalog); + + readonly string _fullTableName = BuildFullTableName(name, schema, catalog); - readonly string _fullTableName; - public string LengthQuery { get; } + static string BuildFullTableName(string name, string schema, string? catalog) => + catalog == null + ? $"{NameHelper.Quote(schema)}.{NameHelper.Quote(name)}" + : $"{NameHelper.Quote(catalog)}.{NameHelper.Quote(schema)}.{NameHelper.Quote(name)}"; public override string ToString() => _fullTableName; + // Builds a SINGLE query that returns the (approximate) length of every supplied table in one + // catalog, read entirely from the system catalog views (sys.partitions/sys.tables/sys.schemas). + // + // Why this is better than the previous per-table IF EXISTS + max-min(RowVersion) probe: + // * One statement covers N queues instead of N statements (the customer in case 00105882 saw + // thousands of statements/min; this collapses them to one per catalog per poll). + // * sys.partitions reports the row count maintained by the engine, so it never reads, scans or + // locks the queue tables themselves — the IF EXISTS guard and the max-min RowVersion scan are + // both gone. Metadata visibility means SELECT permission on the queue tables is enough; no + // VIEW DATABASE STATE is required (unlike the dm_db_partition_stats DMV). + // * The queue tables are heaps (index_id 0) with non-clustered indexes only, so index_id IN (0,1) + // yields the table's row count. + // + // The result is still an approximation — comparable to the existing max-min(RowVersion) estimate, + // which itself over-counts identity gaps — which is acceptable for the queue-length monitoring graph. + public static string BuildBulkLengthQuery(string? catalog, IReadOnlyCollection tables) + { + var prefix = catalog == null ? string.Empty : $"{NameHelper.Quote(catalog)}."; + + // With no tables the predicate would be empty, producing the invalid `AND ()`. Emit a + // constant-false predicate instead so the query stays valid and simply returns no rows. + var predicate = tables.Count == 0 + ? "1 = 0" + : string.Join( + "\n OR ", + tables.Select(t => $"(s.name = '{Escape(t.UnquotedSchema)}' AND t.name = '{Escape(t.UnquotedName)}')")); + + // NOLOCK hints (statement-scoped) rather than SET TRANSACTION ISOLATION LEVEL READ + // UNCOMMITTED: the latter is connection-scoped and would persist on the pooled physical + // connection. The hint keeps the dirty-read scope on this single catalog-view read. + return $""" + SELECT s.name AS TableSchema, t.name AS TableName, SUM(p.rows) AS [RowCount] + FROM {prefix}sys.partitions p WITH (NOLOCK) + INNER JOIN {prefix}sys.tables t WITH (NOLOCK) ON t.object_id = p.object_id + INNER JOIN {prefix}sys.schemas s WITH (NOLOCK) ON s.schema_id = t.schema_id + WHERE p.index_id IN (0, 1) + AND ({predicate}) + GROUP BY s.name, t.name; + """; + } + + static string Escape(string identifier) => identifier.Replace("'", "''"); + public static SqlTable Parse(string address, string defaultSchema) { var parts = address.Split('@');