Skip to content

Commit 1608db0

Browse files
Reuse one query plan per SQL Server ingestion statement (#6011)
* Add ColumnTypes method for parameter size handling and implement ingestion plan reuse tests * Enhance BatchInsert to handle unbounded text parameters and add ingestion plan reuse tests
1 parent 07aab12 commit 1608db0

9 files changed

Lines changed: 337 additions & 30 deletions

File tree

‎src/ServiceControl.Audit.Persistence.EFCore/Infrastructure/BatchInsert.cs‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,16 @@ public static async Task Rows<TEntity>(DbContext dbContext, IReadOnlyCollection<
3535
{
3636
foreach (var column in table.Columns)
3737
{
38-
command.Parameters.Add(column.TypeMapping.CreateParameter(command, $"@p{index++}", column.Read(row), column.IsNullable));
38+
var parameter = column.TypeMapping.CreateParameter(command, $"@p{index++}", column.Read(row), column.IsNullable);
39+
40+
// SQL Server reuses a plan only when the declarations match, and EF declares unbounded text as
41+
// nvarchar(4000) or nvarchar(max) by the value's length. Npgsql ignores a size of -1.
42+
if (column.IsUnboundedText)
43+
{
44+
parameter.Size = -1;
45+
}
46+
47+
command.Parameters.Add(parameter);
3948
}
4049
}
4150

@@ -124,5 +133,8 @@ .. entityType.GetProperties()
124133
static readonly ConcurrentDictionary<(IModel Model, Type EntityType), InsertTable> cache = new();
125134
}
126135

127-
sealed record InsertColumn(string Name, RelationalTypeMapping TypeMapping, bool IsNullable, Func<object, object?> Read);
136+
sealed record InsertColumn(string Name, RelationalTypeMapping TypeMapping, bool IsNullable, Func<object, object?> Read)
137+
{
138+
public bool IsUnboundedText { get; } = (TypeMapping.Converter?.ProviderClrType ?? TypeMapping.ClrType) == typeof(string) && TypeMapping.Size is null;
139+
}
128140
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
namespace ServiceControl.Audit.Persistence.Tests
2+
{
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Text;
6+
using System.Threading.Tasks;
7+
using Microsoft.Data.SqlClient;
8+
using NUnit.Framework;
9+
using ServiceControl.SagaAudit;
10+
11+
// SQL Server reuses a cached plan only when the parameter declarations match too, so these prove
12+
// that an unbounded text column is always declared nvarchar(max), whether its value is null, short or long.
13+
class IngestionPlanReuseTests : EFPersistenceTestFixture
14+
{
15+
[Test]
16+
public async Task Messages_with_no_body_a_short_body_and_a_long_body_share_one_plan()
17+
{
18+
await Ingest(MakeMessage(contentType: "text/plain"));
19+
await Ingest(MakeMessage(contentType: "text/plain"), Encoding.UTF8.GetBytes(new string('x', 10)));
20+
await Ingest(MakeMessage(contentType: "text/plain"), Encoding.UTF8.GetBytes(new string('x', 4001)));
21+
22+
var plans = await CachedPlans("AuditMessages");
23+
24+
Assert.That(plans, Has.Count.EqualTo(1));
25+
Assert.That(plans[0].UseCount, Is.EqualTo(3));
26+
Assert.That(plans[0].Text, Does.Contain("nvarchar(max)").And.Not.Contain("nvarchar(4000)"));
27+
}
28+
29+
[Test]
30+
public async Task Saga_snapshots_with_no_state_a_short_state_and_a_long_state_share_one_plan()
31+
{
32+
await RecordSnapshot(state: null);
33+
await RecordSnapshot(state: new string('x', 10));
34+
await RecordSnapshot(state: new string('x', 4001));
35+
36+
var plans = await CachedPlans("SagaSnapshots");
37+
38+
Assert.That(plans, Has.Count.EqualTo(1));
39+
Assert.That(plans[0].UseCount, Is.EqualTo(3));
40+
Assert.That(plans[0].Text, Does.Contain("nvarchar(max)").And.Not.Contain("nvarchar(4000)"));
41+
}
42+
43+
async Task RecordSnapshot(string state)
44+
{
45+
await using var unitOfWork = await StartAuditUnitOfWork(1);
46+
await unitOfWork.RecordSagaSnapshot(new SagaSnapshot
47+
{
48+
SagaId = Guid.NewGuid(),
49+
SagaType = "Shipping.ShippingPolicy",
50+
Status = SagaStateChangeStatus.Updated,
51+
StartTime = TimeSent,
52+
FinishTime = TimeSent,
53+
StateAfterChange = state
54+
});
55+
await unitOfWork.Complete();
56+
}
57+
58+
async Task<List<(string Text, int UseCount)>> CachedPlans(string table)
59+
{
60+
await using var connection = new SqlConnection(configuration.ConnectionString);
61+
await connection.OpenAsync();
62+
63+
await using var command = connection.CreateCommand();
64+
command.CommandText = """
65+
SELECT st.text, cp.usecounts
66+
FROM sys.dm_exec_cached_plans AS cp
67+
CROSS APPLY sys.dm_exec_sql_text(cp.plan_handle) AS st
68+
WHERE CHARINDEX(@statement, st.text) > 0
69+
""";
70+
command.Parameters.AddWithValue("@statement", $"INSERT INTO [{configuration.Schema}].[{table}]");
71+
72+
var plans = new List<(string Text, int UseCount)>();
73+
await using var reader = await command.ExecuteReaderAsync();
74+
while (await reader.ReadAsync())
75+
{
76+
plans.Add((reader.GetString(0), reader.GetInt32(1)));
77+
}
78+
79+
return plans;
80+
}
81+
}
82+
}

‎src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDialect.cs‎

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,31 @@ abstract class SqlServerDialect
1515
/// </summary>
1616
protected static string Table<TEntity>(ServiceControlDbContext dbContext) => SchemaQualifiedTableName.For<TEntity>(dbContext);
1717

18-
protected static async Task Execute(ServiceControlDbContext dbContext, string sql, IEnumerable<object?[]> rows, CancellationToken cancellationToken = default)
18+
/// <summary>
19+
/// How to declare each column of a statement's rows, taken from the properties the columns are
20+
/// mapped to. Pass the property names in the order the values sit in each row. A row may be
21+
/// shorter than this list, but not longer. A text column gets its length as its size, or -1 for nvarchar(max).
22+
/// </summary>
23+
protected static ColumnType[] ColumnTypes<TEntity>(ServiceControlDbContext dbContext, params string[] properties)
24+
{
25+
var entityType = dbContext.Model.FindEntityType(typeof(TEntity))
26+
?? throw new InvalidOperationException($"{typeof(TEntity).Name} is not part of the model.");
27+
28+
return [.. properties.Select(name =>
29+
{
30+
var property = entityType.FindProperty(name)
31+
?? throw new InvalidOperationException($"{typeof(TEntity).Name} has no property {name}.");
32+
33+
var dbType = property.GetRelationalTypeMapping().DbType
34+
?? throw new InvalidOperationException($"{typeof(TEntity).Name}.{name} has no DbType in its type mapping.");
35+
36+
return new ColumnType(dbType, dbType == DbType.String ? property.GetMaxLength() ?? -1 : null);
37+
})];
38+
}
39+
40+
protected readonly record struct ColumnType(DbType DbType, int? Size);
41+
42+
protected static async Task Execute(ServiceControlDbContext dbContext, string sql, ColumnType[] columnTypes, IEnumerable<object?[]> rows, CancellationToken cancellationToken = default)
1943
{
2044
await using var command = dbContext.Database.GetDbConnection().CreateCommand();
2145
command.Transaction = (dbContext.Database.CurrentTransaction
@@ -25,18 +49,36 @@ protected static async Task Execute(ServiceControlDbContext dbContext, string sq
2549
var index = 0;
2650
foreach (var row in rows)
2751
{
28-
foreach (var value in row)
52+
for (var column = 0; column < row.Length; column++)
2953
{
54+
var value = row[column];
3055
var parameter = command.CreateParameter();
3156
parameter.ParameterName = $"@p{index++}";
3257
parameter.Value = value ?? DBNull.Value;
3358

34-
// Attempt de-duplication compares LastAttemptedAt for equality, so datetime
35-
// parameters must keep datetime2 precision instead of the datetime default.
59+
// Attempt de-duplication compares LastAttemptedAt for equality, so a datetime keeps
60+
// datetime2 precision wherever it sits, including the lone "now" that ResolveRetriedMessages sends first.
3661
if (value is DateTime)
3762
{
3863
parameter.DbType = DbType.DateTime2;
3964
}
65+
else
66+
{
67+
// SQL Server reuses a plan only when the declarations match, so every value, null or not,
68+
// is declared by its column. A string longer than its column keeps its own length,
69+
// because SqlClient would silently cut it and SQL Server must refuse it instead.
70+
var (dbType, size) = columnTypes[column];
71+
if (value is string && dbType != DbType.String)
72+
{
73+
throw new InvalidOperationException($"Parameter {parameter.ParameterName} holds text, but its column is declared as {dbType}.");
74+
}
75+
76+
parameter.DbType = dbType;
77+
if (size is { } columnSize)
78+
{
79+
parameter.Size = value is string text && columnSize != -1 && text.Length > columnSize ? text.Length : columnSize;
80+
}
81+
}
4082

4183
command.Parameters.Add(parameter);
4284
}

‎src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerEndpointThroughputDialect.cs‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
namespace ServiceControl.Persistence.EFCore.SqlServer;
22

3-
using System.Data;
4-
using System.Text;
5-
using Microsoft.EntityFrameworkCore;
6-
using Microsoft.EntityFrameworkCore.Storage;
73
using Particular.LicensingComponent.Contracts;
84
using ServiceControl.Persistence.EFCore.DbContexts;
95
using ServiceControl.Persistence.EFCore.Entities;
@@ -29,6 +25,12 @@ await Execute(
2925
WHEN NOT MATCHED THEN INSERT (NormalizedName, ThroughputSource, DateUtc, MessageCount)
3026
VALUES (s.NormalizedEndpointName, s.Source, s.Day, s.Count);
3127
""",
28+
ColumnTypes<LicensingEndpointThroughputEntity>(
29+
dbContext,
30+
nameof(LicensingEndpointThroughputEntity.NormalizedName),
31+
nameof(LicensingEndpointThroughputEntity.ThroughputSource),
32+
nameof(LicensingEndpointThroughputEntity.DateUtc),
33+
nameof(LicensingEndpointThroughputEntity.MessageCount)),
3234
[[normalizedName, (int)throughputSource, date, messageCount]],
3335
cancellationToken);
3436
}

‎src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerFailedMessageIngestionSqlDialect.cs‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ await Execute(
2929
WHEN NOT MATCHED THEN INSERT ({FailedMessageColumnList})
3030
VALUES ({FailedMessageSourceColumnList});
3131
""",
32+
ColumnTypes<FailedMessageEntity>(dbContext, FailedMessageColumns),
3233
chunk.Select(FailedMessageValues),
3334
cancellationToken);
3435
}
@@ -50,6 +51,12 @@ await Execute(
5051
WHEN NOT MATCHED THEN INSERT ([FailedMessageUniqueId], [GroupId], [Title], [Type])
5152
VALUES (s.[FailedMessageUniqueId], s.[GroupId], s.[Title], s.[Type]);
5253
""",
54+
ColumnTypes<FailedMessageGroupEntity>(
55+
dbContext,
56+
nameof(FailedMessageGroupEntity.FailedMessageUniqueId),
57+
nameof(FailedMessageGroupEntity.GroupId),
58+
nameof(FailedMessageGroupEntity.Title),
59+
nameof(FailedMessageGroupEntity.Type)),
5360
chunk.Select(group => new object?[] { group.FailedMessageUniqueId, group.GroupId, group.Title, group.Type }),
5461
cancellationToken);
5562
}
@@ -71,6 +78,13 @@ await Execute(
7178
WHEN NOT MATCHED THEN INSERT ([Id], [Name], [HostId], [Host], [Monitored])
7279
VALUES (s.[Id], s.[Name], s.[HostId], s.[Host], s.[Monitored]);
7380
""",
81+
ColumnTypes<KnownEndpointEntity>(
82+
dbContext,
83+
nameof(KnownEndpointEntity.Id),
84+
nameof(KnownEndpointEntity.Name),
85+
nameof(KnownEndpointEntity.HostId),
86+
nameof(KnownEndpointEntity.Host),
87+
nameof(KnownEndpointEntity.Monitored)),
7488
chunk.Select(endpoint => new object?[] { endpoint.Id, endpoint.Name, endpoint.HostId, endpoint.Host, endpoint.Monitored }),
7589
cancellationToken);
7690
}
@@ -98,7 +112,11 @@ INNER JOIN (VALUES
98112
ON t.[UniqueMessageId] = s.[UniqueMessageId]
99113
WHERE t.[LastAttemptedAt] <= s.[SucceededAt];
100114
""",
101-
[[now], .. chunk.Select(retry => new object?[] { retry.UniqueMessageId, retry.SucceededAt })],
115+
ColumnTypes<FailedMessageEntity>(
116+
dbContext,
117+
nameof(FailedMessageEntity.UniqueMessageId),
118+
nameof(FailedMessageEntity.LastAttemptedAt)),
119+
[[now], .. chunk.Select(retry => new object?[] { retry.UniqueMessageId, retry.SucceededAt })],
102120
cancellationToken);
103121
}
104122
}

‎src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerRetryBatchSqlDialect.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ await Execute(
2222
WHEN NOT MATCHED THEN INSERT ([UniqueMessageId], [RetryBatchId], [StageAttempts])
2323
VALUES (s.[UniqueMessageId], s.[RetryBatchId], s.[StageAttempts]);
2424
""",
25+
ColumnTypes<FailedMessageRetryEntity>(
26+
dbContext,
27+
nameof(FailedMessageRetryEntity.UniqueMessageId),
28+
nameof(FailedMessageRetryEntity.RetryBatchId),
29+
nameof(FailedMessageRetryEntity.StageAttempts)),
2530
chunk.Select(retry => new object?[] { retry.UniqueMessageId, retry.RetryBatchId, retry.StageAttempts }),
2631
cancellationToken);
2732
}

0 commit comments

Comments
 (0)