SqlClient … to trim or not to trim

My relationship with SQL Server has been a bit strained recently. I always enjoyed working with SQL Server for over 20 years now, all the way back to SQL Server 2000. I love many aspects of it, from T-SQL, and I find Service Broker very cool and once even wrote a WCF binding for it😱.

The .NET SqlClient package has been a recurring source of pain in the past years. It doesn’t support trimming or AOT, its dependency on Azure.Identity (and its share of security vulnerabilities) which you don’t need when talking to your on-premises db, and it has recently seen a couple of NuGet versions delisted for … well … bugs (6.0.0 and 6.1.0 most recently).

But hey, I stay when it’s hard, or it’s wrong, or we’re making mistakes.

Officially, Microsoft.Data.SqlClient does not support trimmed and AOT (PublishTrimmed / PublishAot) publishing. That said, based on my experience, it can work for many basic scenarios (SqlConnection, SqlCommand) if you apply a few configurations and know how to diagnose the issues which may appear, which may or may not be obvious. This post explains what I do, why it helps, and how to diagnose the silence (handled exceptions and missing diagnostics) you will see.

My scenario is mostly AOT published ASP.NET .NET 8/9/10 web apis which use SqlClient and are containerized and run on linux. I have seen a small loss of fidelity, but no major issues.

Trimming vs AOT …

Trimming and AOT (ahead-of-time) are related but distinct: both remove unused code paths from your app. While trimming modifies the IL code of your application, your libraries and the .NET core libraries (yes, these get the treatment as well), it keeps IL as output and still requires the JIT at runtime. AOT basically performs the native compilation (which the JIT compiler would during run-time) at compile-time.

PublishTrimmed runs the IL linker to remove unused IL to reduce size. It focuses on managed code that appears unused. The tool is called ILLink and uses Mono.Cecil to analyze and rewrite the IL code. It basically walks and marks every type, method and member which is accessed in every execution path of an application. Everything which does not appear to be touched gets thrown out. It is a tad more complex than that, but basically, that is what it does. Code is here: ILLink code on GitHub in the dotnet/runtime repo

PublishAot (AOT) compiles IL to native code ahead-of-time and can include additional link-time processing. Trimming is not a prerequisite for AOT; they are completely separate.

See the runtime team discussion on AOT internals and how AOT handles reflection and preservation patterns: GitHub discussion trimming vs. AOT

Basically, contrary to ILLink, which removes unused code paths from assemblies, ILCompiler simply does not generate what is not needed. AOT will most likely reduce startup time and reduce the memory footprint. It will still use the .NET runtime (garbage collector …) at run-time, this gets statically linked to your application.

Both forms take a normal .NET build as input. The first edits the emitted assemblies, while the latter basically JIT compiles the emitted assemblies.

What may cause issues with Microsoft.Data.SqlClient

  • Reflection* and configuration probing: SqlClient probes configuration APIs and expects certain types/members to exist. If the linker removes them, you’ll see exceptions during the probe (often caught internally). Note that SqlClient still uses “old” configuration apis from System.Configuration.ConfigurationManager.
  • Native interop (Windows SNI): On Windows, SqlClient uses SNI (native) and relies on marshaling helpers. Trimming/AOT can remove those helpers.
  • Diagnostic/telemetry paths: EventSource/DiagnosticSource/Activity creation and enrichment can be affected by AOT/trimming, leading to missing or partial telemetry.

Reflection …

… can basically be two things: parsing your code metadata at runtime, and accessing fields you probably shouldn’t. Or executing code in a late bound fashion, using mechanisms like Activator.CreateInstance.

Reflection can also be generating code at runtime (like serialization classes used to be) via Reflection.Emit. Using reflection to generate code at runtime is basically verboten in trimmed or AOT scenarios and has been replaced by source generators.

Reflection to access code metadata or execute code like Activator.CreateInstance will likely continue to work, but may need additional metadata to tell the trimmer what can not be removed even though it does not appear in any execution path.

Support for the trimmer

Normally, the IL code in referenced assemblies (after these have been made trimming compatible) will be annotated with metadata/attributes to tell the trimmer what not to remove (for example DynamicallyAccessedMembersAttribute). In case a library has not been annotated, you can still tell the trimmer or ilcompiler via configuration in your .csproj. Examples are the <TrimmerRootAssembly ... /> and <TrimmerRootDescriptor ... /> properties mentioned below.

How to diagnose exceptions the library handles internally

Basically three things can happen when you trim or AOT compile an application using a library which does not officially support it

  1. the app crashes on startup because its initialization performs something affected by trimming/AOT. This is good because such an unhandled exceptions directly or indirectly tells you what broke. Running SqlClient on Windows in trimmed mode will throw an unhandled exception the first time you are trying to use SqlClient functionality.

  2. the app works but internally throws some or a lot of exceptions which the library code catches, but something is broken or could be configured differently to prevent this. SqlClient in trimmed or AOT mode will do this with at least the configuration system.

  3. the app does not crash immediately but at some point when it hits an execution path affected by trimming.

In all cases, exceptions, either handled or not will appear in your program. There are different mechanisms to inspect these, from OTel metrics, dotnet-counters, trace tools (like dotTrace), PerfView, to first chance exceptions handlers.

SqlClient often catches exceptions internally - these are handled, but they still indicate missing members or other problems. Surface them early while developing:

  • AppDomain first-chance exceptions

Sometimes, choosing a hammer over a more sophisticated tool can be fun … just subscribe to first-chance exceptions to log all exceptions (possibly to a file) as they’re thrown (even when handled). Then feed that exception file to CoPilot.

AppDomain.CurrentDomain.FirstChanceException += (s, e) =>
{
    Console.Error.WriteLine($"FirstChance: {e.Exception.GetType()}: {e.Exception.Message}\n{e.Exception.StackTrace}");
};

This reveals reflection exceptions, null refs, and other probe-time failures even if swallowed.

Changing the build

To get rid of some of the issues, we can take the following steps.

TrimmerRootDescriptor, rd.xml and minimal preserves

Changes that make SqlClient usable are conservative preserves and an explicit configuration package reference.

1) PackageReference (csproj)

Add System.Configuration.ConfigurationManager explicitly to ensure configuration types exist at runtime:

<ItemGroup>
  <PackageReference Include="System.Configuration.ConfigurationManager" Version="*" />
</ItemGroup>

2) Trimmer root descriptor (rd.xml)

Reference an rd.xml from the project via <TrimmerRootDescriptor Include="rd.xml" />. Example snippets:

Linux / general (rd.xml):

<linker>
  <assembly fullname="System.Configuration.ConfigurationManager">
    <type fullname="System.Configuration.ClientConfigurationHost" preserve="all"/>
  </assembly>
</linker>

Windows (rd.xml):

<linker>
  <assembly fullname="System.Configuration.ConfigurationManager">
    <type fullname="System.Configuration.ClientConfigurationHost" preserve="all"/>
  </assembly>
  <assembly fullname="System.Private.CoreLib">
    <type fullname="System.StubHelpers.InterfaceMarshaler" preserve="all"/>
  </assembly>
</linker>

Notes: I used preserve=”all” during development to eliminate spurious handled exceptions; you can iterate and tighten these once you identify the precise members required.

Final notes and offers

not supported officially, use at your own discretion.