Skip to content

Commit ded930e

Browse files
Enhance BatchInsert to handle unbounded text parameters and add ingestion plan reuse tests
1 parent 0d39474 commit ded930e

2 files changed

Lines changed: 96 additions & 2 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+
}

0 commit comments

Comments
 (0)