Functions

Nexus functions are a lightweight way of executing a method on a service in the background. Sometimes you want to execute some method but you want it to run in the background. Either because the method takes a long time or because you want to make sure that it's retried until it completes successfully.

Let's take a simplified example:

public class OrderController : Controller
{
    private readonly IOrderConfirmationEmailService _orderConfirmationEmailService;

    public OrderController(IOrderConfirmationEmailService orderConfirmationEmailService)
    {
        _orderConfirmationEmailService = orderConfirmationEmailService;
    }

    public async Task<IActionResult> OrderPlaced(string orderId)
    {
        await _orderConfirmationEmailService.SendEmailForOrderAsync(orderId);

        return Json(new
        {
            Success = true,
        });
    }
}

We need to send the order confirmation email, but there's a couple of issues with this approach. What happens if SendEmailForOrderAsync() throws an error? And what if it takes a couple of seconds to complete? If it throws an exception we'll miss sending the email for that order. And if it takes a couple of seconds we'll slow down the experience for the end customer.

We can fix that by using a Nexus function instead, like this:

public class OrderController : Controller
{
    private readonly INexusFunction _nexusFunction;

    public OrderController(INexusFunction nexusFunction)
    {
        _nexusFunction = nexusFunction;
    }

    public async Task<IActionResult> OrderPlaced(string orderId)
    {
        await _nexusFunction.RunInBackgroundAsync<IOrderConfirmationEmailService>(x => x.SendEmailForOrderAsync(orderId));

        return Json(new
        {
            Success = true,
        });
    }
}

With this change we've moved the actual execution of the SendEmailForOrderAsync() method into the background (maybe even to a different server). When Nexus executes the method it will catch any exceptions that happen and retry the call later. You can control the retry behavior using the [NexusFunction] attribute which you can read about below.

Registering functions

The only things you need to do is to call AddFunctions() like this:

builder.Services.AddNexus().AddFunctions();

And then install either CommerceMind.Nexus.Sqlite, CommerceMind.Nexus.Postgres, or CommerceMind.Nexus.SqlServer and initialize it:

builder.Services
    .AddNexus()
    .AddFunctions()
    // Either:
    .AddSqliteConnection()
    // Or:
    .AddPostgresConnection()
    // Or:
    .AddSqlServerConnection()

    .Build();

After that you can use INexusFunction and start running methods in the background. Note that by default the application will also start to run functions in the background. If you want an application to only add functions but not run them you can set UseBackgroundService to false like this:

builder.Services.AddNexus().AddFunctions(options =>
{
    options.UseBackgroundService = false;
});

You must have at least one application that runs functions so this is only if you have multiple applications where one or more is dedicated for background processing.

If you're not using a continously running server and instead using something like Azure Functions or Azure Container Instances that start on a schedule you can set UseBackgroundService to false and instead call INexusFunctionExecutorService.RunAllFunctionsPendingSinceLastCallAsync() from your entrypoint. That method will run all functions that have been scheduled to run since the last time that method was called. See the example Azure function trigger here.

Keeping processed functions

By default Nexus will store meta data about processed functions for one day in the database before deleting them. You can control this by setting AddNexus().AddFunctions(options => options.KeepProcessedRunsFor = TimeSpan.Zero);. You can also set the retention per function using the [NexusFunction] attribute which you can read about below.

Service registration

You can use any service you want when calling RunInBackgroundAsync<TService>(). The only requirement is that the service is registered in the IServiceCollection. Which means that you can use interfaces as well as long as you register an implementation for it. So for the IOrderConfirmationEmailService example above you'd do something like this:

serviceCollection.AddSingleton<IOrderConfirmationEmailService, MyOrderConfirmationEmailService>();

Multi threading

If the same method on the same service is called multiple times Nexus can sometimes call the same service in multiple threads at the same time. Make sure that all services that you use with Nexus functions has thread safe implementations.

Invoking functions in the Admin UI

If you want to be able to invoke functions yourself through the Admin UI you need to make Nexus aware of which methods you intend to use as a function. Nexus allows invoking any method using INexusFunction in code, but requires your methods to be decorated with the [NexusFunction] attribute in order to list them as a function in the admin UI.

When invoking a function in the admin UI you input the arguments to the method as JSON.

Method arguments

Nexus will automatically capture the values of arguments passed to the method like in the example above with the orderId argument. The only requirement is that it can only be values that can be serialized to JSON. Which means that you can pass strings, numbers, dates, and data objects to the service method. But you can't pass another service for example. If you have a method that requires another service as an argument you need to create a separate Nexus friendly method. Something like this:

public class ExampleService
{
    private readonly OtherService _service;

    public ExampleService(OtherService service)
    {
        _service = service;
    }

    public void ExampleMethod(OtherService service, string someString)
    {
        service.SomeMetod(someString);
    }

    public void ExampleMethod(string someString)
    {
        ExampleMethod(_service, someString);
    }
}

So instead of having Nexus call the ExampleMethod(OtherService service, string someString) method you'd let it call ExampleMethod(string someString). You can of course create a separate service that does this instead of having the method in the same service.

If you need to change the way arguments are serialized you can register a custom implementation of INexusFunctionArgumentsSerializer with the service provider.

Cancellation token

The methods on INexusFunction has overloads that passes a cancellation token to the provided function. This lets you pass the cancellation token on to your service method like this:

await _nexusFunction.RunInBackgroundAsync<ISomeService>((s, ct) => s.LongOperation(ct));

The cancellation token passed to the service is the graceful shutdown token that will be triggered either if a graceful shutdown has been initiated or if the application is shutting down because of a deploy or server restart.

Running a method after a delay

If you don't want to run the method as fast as possible you can call RunInBackgroundAfterAsync<TService>() instead of RunInBackgroundAsync<TService>(). Like this:

await _nexusFunction.RunInBackgroundAfterAsync<IOrderConfirmationEmailService>(TimeSpan.FromMinutes(10), x => x.SendEmailForOrderAsync(orderId));

Running a method until a condition is met

Sometimes you want to keep running a method until some condition is true such as polling an external system. Let's say that you don't want to send the order confirmation email until the customer on the order has been exported to the CRM.

You can use recursion to let a method called by Nexus schedule a new run, something like this:

public MyOrderConfirmationEmailService
{
    private readonly ICustomerService _customerService;
    private readonly IOrderService _orderService;
    private readonly INexusFunction _nexusFunction;

    public MyOrderConfirmationEmailService(ICustomerService customerService, IOrderService orderService, INexusFunction nexusFunction)
    {
        _customerService = customerService;
        _orderService = orderService;
        _nexusFunction = nexusFunction;
    }

    public async Task SendEmailForOrderAsync(string orderId)
    {
        var order = await _orderService.GetAsync(orderId);
        if (!await _customerService.ExistsAsync(order.CustomerNumber))
        {
            // Wait 5 minutes to see if the customer has been exported
            await _nexusFunction.RunInBackgroundAfterAsync<IOrderConfirmationEmailService>(TimeSpan.FromMinutes(5), x => x.SendEmailForOrderAsync(orderId));
        }
        else
        {
            // Customer exists, let's break the recursion and send the email
            await SendEmailAsync(order);
        }
    }

    private async SendEmailAsync(Order order)
    {
        // Send the email
    }
}

In this example Nexus will continuously call SendEmailForOrderAsync(orderId) every five minutes until the customer has been exported.

[NexusFunction] attribute

The [NexusFunction] attribute lets you control some aspects of how Nexus will run a method. You specify the attribute either on the service class/interface or on individual methods.

You can use [NexusFunction] to control for how long meta data about successful runs of a method is stored like this:

[NexusFunction(KeepProcessedRunFor = "10")]
public void ExampleMethod()
{

}

This will store all calls to this method for 10 days as the value for KeepProcessedRunFor must be a string that can be parsed to a TimeSpan using TimeSpan.Parse().

Retries

By default Nexus will retry a function if an exception is thrown. The default strategy is to use linear back-off where the first retry is after 5 seconds, the second retry is after 10 seconds, the third is fter 15 seconds, etc.

You can control the delay and the max number of retries globally by setting the RetryAfter and MaxRetries options like this:

builder.Services.AddNexus().AddFunctions(options =>
{
    options.RetryAfter = TimeSpan.FromSeconds(10);
    options.MaxRetries = 5;
});

But you can also set the max retry count on a function level like this:

[NexusFunction(MaxRetries = 5)]
public void ExampleMethod()
{

}

By default Nexus will keep calling the method forever until it completes successfully without any max retry number.

You can change the retry strategy from linear back-off to either NexusFunctionRetries.RetryUntilSuccess or NexusFunctionRetries.ManualRetry. NexusFunctionRetries.RetryUntilSuccess will keep calling the method without any delay whereas NexusFunctionRetries.ManualRetry won't retry at all. You can trigger a retry manually through the Admin UI or the API.

[NexusFunction(Retries = NexusFunctionRetries.RetryUntilSuccess)]
public void ExampleMethod()
{

}

Function middlewares

In some cases you want to wrap the execution of a function. You might want to add additional log context properties or instrument the function execution in some way. To achieve this you can register one or more INexusFunctionMiddleware instances in the service collection. Eg:

public class MyFunctionMiddleware(ILogger<MyFunctionMiddleware> logger) : INexusFunctionMiddleware
{
    public async Task<NexusFunctionResult> ExecuteAsync(NexusFunctionDescriptor nexusFunctionDescriptor, Func<Task<NexusFunctionResult>> executeFunction)
    {
        using logger.BeingScope(new Dictionary<string, object> {{ "FunctionName", nexusFunctionDescriptor.DisplayName }});
        return await executeFunction();
    }
}

// Register the middleware
builder.Services.AddSingleton<INexusFunctionMiddleware, MyFunctionMiddleware>();

How Nexus Functions works

When you call RunInBackgroundAsync<TService>(x => x.SomeMethod(someArgument)) Nexus will use .NET reflection to gather information about what it is you want to run. The full name of the service type, the name of the method to use and the arguments passed.

This is then stored in the database in a row that looks something like this:

status service_type method parameter_types_json arguments_json run_at_utc
"Pending" "My.Namespace.IOrderConfirmationEmailService, MyAssembly" "SendEmailForOrderAsync" ["System.String, System"] ["123"] "2022-01-01T00:00:00"

Nexus will then read this data from the database and again use reflection to locate the .NET types needed and deserialize the arguments from JSON. It will then locate the service through the IServiceProvider and execute the method in a background service.

The type signature for RunInBackgroundAsync<TService>() is a lambda that gets the TService passed in and either returns a Task or nothing. So the type system allows you to write something like this:

await _nexusFunction.RunInBackgroundAsync<IOrderConfirmationEmailService>(x => orderId == "123" ? x.SendEmailForOrderAsync(orderId) : x.DoSomethingElseAsync(orderId));

Nexus will however throw an exception if you do this as the only allowed lambda to pass in is a method call on the service passed into the lambda.

You can think of it as a more readable and type safe way of writing this:

await _nexusFunction.RunInBackgroundAsync(typeof(IOrderConfirmationEmailService), nameof(IOrderConfirmationEmailService.SendEmailForOrderAsync), new object?[] { orderId });

Return value

The method you use in your lambda is allowed to return a value for synchronous methods and Task or Task<T> for asynchronous methods but Nexus won't do anything with the return value. If you need to do something with the return value you should wrap the method in another method that does something with it. If you want to call a method that returns false if it didn't go as expected you should wrap it in a method that throws an exception instead. Something like this:

public class ExampleService
{
    public bool TryDoSomething()
    {
        // Do something interesting here and return true/false
    }

    [NexusFunction]
    public void DoSomething()
    {
        if (!TryDoSomething())
        {
            throw new Exception("It didn't go as planned");
        }
    }
}

Renaming classes or methods

Since Nexus will store full type names such as My.Namespace.MyService, MyAssembly in the database it's important not to rename classes that are used as Nexus functions. If you rename such a class or method existing functions will fail and you need to update the database with the new type names.