Dataverse: Create ServiceClient Strategy

I just realized creating ServiceClient is an expensive operation (I found one blog post that confirmed this problem even though this was an article from 2017). During one of the API call tests, almost half of the time was wasted on the creation of ServiceClient. Before, I always thought we needed to use one ServiceClient per scope (assuming you will create the ServiceClient based on the App User). So, in this blog post, we will compare the "cache" vs the new instance of ServiceClient per call.

Benchmark Code

To prove this, I have 3 Dataverse connection strings:

Connection Strings

Connection Strings

In the below code, you can see the Startup and also connection logic:

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.PowerPlatform.Dataverse.Client;

namespace DataverseBenchmarkProject;

public static class Startup
{
    public static IHost GetApplicationHost()
    {
        var hostBuilder = new HostBuilder()
            .ConfigureAppConfiguration(builder => builder.SetBasePath(Directory.GetCurrentDirectory())
                .AddJsonFile("appSettings.json", optional: false, reloadOnChange: true))
            .ConfigureServices((context, services) =>
            {
                var connectionStrings = context.Configuration.AsEnumerable().Where(e => e.Key.Contains("DataverseConnectionString")).Select(e => e.Value).ToArray();
                services.AddSingleton(new ConnectionString(connectionStrings));
            });

        return hostBuilder.Build();
    }
}

public record ConnectionString(string[] ConnectionStrings);

public class XrmConnection
{
    private static readonly object _lock = new();
    public XrmConnection(ConnectionString connectionString)
    {
        Connections = connectionString.ConnectionStrings.Select(e => new Connection { ConnectionString = e }).ToArray();
    }
    public static Connection[] Connections { get; private set; }

    public string GetConnectionString()
    {
        lock (_lock)
        {
            var minValue = Connections.Min(e => e.Counter);
            var index = -1;
            for (int i = 0; i < Connections.Length; i++)
            {
                if (Connections[i].Counter != minValue) continue;
                index = i;
                break;
            }
            Connections[index].Counter++;

            return Connections[index].ConnectionString;
        }
    }

    public ServiceClient GetServiceClient()
    {
        lock (_lock)
        {
            var minValue = Connections.Min(e => e.Counter);
            var index = -1;
            for (int i = 0; i < Connections.Length; i++)
            {
                if (Connections[i].Counter != minValue) continue;
                index = i;
                break;
            }

            return Connections[index].GetServiceClient();
        }
    }
}

public class Connection
{
    public Connection()
    {
        Counter = 0;
        ExpiredOn = DateTime.MinValue;
    }

    public string ConnectionString { get; set; }
    public int Counter { get; set; }
    public DateTime? ExpiredOn { get; set; }

    private static ServiceClient? _serviceClient = null;
    public ServiceClient GetServiceClient()
    {
        Counter += 1;
        if (ExpiredOn.GetValueOrDefault() <= DateTime.UtcNow)
        {
            // https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens
            ExpiredOn = DateTime.UtcNow.AddMinutes(45);
            _serviceClient = new ServiceClient(ConnectionString);

            Console.WriteLine($"New ServiceClient created. Counter: {Counter}. ExpiredOn: {ExpiredOn}");
        }
        return _serviceClient;
    }
}

Basically for the Startup.cs, we just need to retrieve the connection string information. Then, we will create an XrmConnection instance where it will contain an array of Connection.

In the Connection class, we will keep the ConnectionString, Counter, and also ExpiredOn. Based on this article, the access token from the Microsoft identity platform will be valid for an average of 75 minutes:

Validate token expiry is around 1 hour based on my manual test

Validate token expiry is around 1 hour based on my manual test

So, assuming, 75 minutes is the average, I set the code to always renew in 45 minutes since ServiceClient was created (GetServiceClient method).

And the below is the benchmark code:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using Microsoft.Crm.Sdk.Messages;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.PowerPlatform.Dataverse.Client;
using Microsoft.Xrm.Sdk;

namespace DataverseBenchmarkProject;

[MemoryDiagnoser]
[Config(typeof(Config))]
[Orderer(BenchmarkDotNet.Order.SummaryOrderPolicy.FastestToSlowest)]
[SimpleJob(launchCount: 1, warmupCount: 0)]
public class CreateServiceClientBenchmark
{
    private class Config : ManualConfig
    {
        public Config()
        {
            SummaryStyle = DefaultConfig.Instance.SummaryStyle
                .WithTimeUnit(Perfolizer.Horology.TimeUnit.Millisecond);
        }
    }

    public CreateServiceClientBenchmark()
    {
        var connectionStrings = Startup.GetApplicationHost().Services.GetService<ConnectionString>()!;
        _xrmConnection1 = new XrmConnection(connectionStrings);
        _xrmConnection2 = new XrmConnection(connectionStrings);
    }

    private readonly XrmConnection _xrmConnection1;
    [Benchmark]
    public void BenchmarkCacheServiceClient()
    {
        var serviceClient = _xrmConnection1.GetServiceClient();
    }

    [Benchmark]
    public void BenchmarkCacheServiceClientWithExecuteWhoAmI()
    {
        var serviceClient = _xrmConnection1.GetServiceClient();
        ExecuteWhoAmI(serviceClient);
    }

    private readonly XrmConnection _xrmConnection2;
    [Benchmark]
    public void BenchmarkCreateServiceClient()
    {
        var serviceClient = new ServiceClient(_xrmConnection2.GetConnectionString());
    }

    [Benchmark]
    public void BenchmarkCreateServiceClientWithExecuteWhoAmI()
    {
        var serviceClient = new ServiceClient(_xrmConnection2.GetConnectionString());
        ExecuteWhoAmI(serviceClient);
    }

    private void ExecuteWhoAmI(IOrganizationService service)
    {
        var res = (WhoAmIResponse)service.Execute(new WhoAmIRequest());
        Console.WriteLine($"{DateTime.Now} - User ID: {res.UserId}. Business Unit: {res.BusinessUnitId}. Org Id: {res.OrganizationId}..");
    }
}

Here is the result of the benchmark:

BenchmarkDotNet v0.14.0, Windows 11 (10.0.22631.4112/23H2/2023Update/SunValley3)
AMD Ryzen 5 5600G with Radeon Graphics, 1 CPU, 12 logical and 6 physical cores
.NET SDK 8.0.300
  [Host]     : .NET 8.0.5 (8.0.524.21615), X64 RyuJIT AVX2
  Job-XGXBQS : .NET 8.0.5 (8.0.524.21615), X64 RyuJIT AVX2

LaunchCount=1  WarmupCount=0  
Method Mean Error StdDev Median Allocated
BenchmarkCacheServiceClient 0.0106 ms 0.0017 ms 0.0050 ms 0.0087 ms 1.03 KB
BenchmarkCacheServiceClientWithExecuteWhoAmI 266.0957 ms 2.1683 ms 1.8106 ms 265.9349 ms 66.95 KB
BenchmarkCreateServiceClient 445.6186 ms 10.8126 ms 31.8811 ms 442.6206 ms 374.58 KB
BenchmarkCreateServiceClientWithExecuteWhoAmI 1,456.1922 ms 27.9466 ms 34.3210 ms 1,458.3642 ms 489.63 KB

In the above results, you can see the comparison between cached (aka Singleton) vs always creating ServiceClient (0.0106 ms vs 445.6186 ms). Then, I also added the differences when we need to call WhoAmIRequestso you know that the ServiceClient is a valid object.

Hope you learn something and happy CRM-ing!

Leave a comment

Your comment is sent privately to the author and isn't published on the site.