Skip to content

Worker Author Guide

Authoring a new worker in the recommended shape: three files, two csprojs, one shared handler logic, and an IGatewayClient for backend calls. This guide walks through the pieces using a fictional EchoWorker (which has three commands: start, stop, echo) and a real reference (the WebSocketManagerController worker).

src/
├── Virtufin.Worker.EchoWorker.Managed/             # managed csproj
│   ├── Virtufin.Worker.EchoWorker.csproj
│   ├── EchoWorker.cs                              # managed shim
│   ├── EchoWorkerLogic.cs                         # shared static logic
│   └── Command.cs                                 # command enum
└── Virtufin.Worker.EchoWorker.Native/              # AOT csproj
    ├── Virtufin.Worker.EchoWorker.Native.csproj
    └── EchoWorkerNative.cs                         # AOT shim

Three files of source. The shared logic is 99% of the code; the two shims are 30 and 50 lines respectively.

Step 1: the command enum

Command.cs is shared between both targets and defines the wire command names. Lowercase identifier names are the wire strings; the WM's CommandWorker<T>.HandleCommandAsync parses them via Enum.TryParse<T>(command, ignoreCase: true, out _).

namespace Virtufin.Worker.EchoWorker;

public enum Command
{
    start,
    stop,
    echo,
}

Step 2: the shared handler logic

EchoWorkerLogic.cs contains the bulk of the worker code. It's a static class with one entry point: HandleCommandAsync(input, command, node, gateway). The handler dispatches on the command enum and calls the gateway through IGatewayClient — the only transport-aware piece in the file.

using System.Text.Json.Nodes;
using CloudNative.CloudEvents;
using Virtufin.Worker.DevKit;

namespace Virtufin.Worker.EchoWorker;

public static class EchoWorkerLogic
{
    // Worker-wide state. For a single-instance worker (the WM's engine
    // model), this is safe: only one CloudEvent is in flight at a
    // time per worker.
    private static volatile bool _echoing = false;

    public static async Task<JsonObject?> HandleCommandAsync(
        CloudEvent input, Command command, JsonNode node, IGatewayClient gateway)
    {
        return command switch
        {
            Command.echo    => await HandleEchoAsync(input, node, gateway),
            Command.start   => await HandleStartAsync(input),
            Command.stop    => await HandleStopAsync(input),
            _ => ErrorPayload(input, $"Unhandled command: {command}")
        };
    }

    private static async Task<JsonObject?> HandleEchoAsync(
        CloudEvent input, JsonNode node, IGatewayClient gateway)
    {
        if (!_echoing) {
            return Response(input, "echo", success: false,
                message: "echoing is stopped; send 'start' first");
        }
        var message = (string?)node["message"] ?? "";
        return Response(input, "echo", success: true, echo: message);
    }

    private static Task<JsonObject?> HandleStartAsync(CloudEvent input) {
        _echoing = true;
        return Task.FromResult(Response(input, "start", success: true, echoing: true));
    }

    private static Task<JsonObject?> HandleStopAsync(CloudEvent input) {
        _echoing = false;
        return Task.FromResult(Response(input, "stop", success: true, echoing: false));
    }

    // Response shape: { command, success, ..., extras }. Single source of
    // truth for the response envelope. Subclasses can override.
    internal static JsonObject Response(CloudEvent input, string command, bool success, ...)
        => new JsonObject {
            ["command"] = command,
            ["success"] = success,
            // ... per-handler extras
        };

    internal static JsonObject ErrorPayload(CloudEvent input, string message)
        => new JsonObject {
            ["command"] = "error",
            ["success"] = false,
            ["message"] = message
        };
}

The handler body is byte-identical for both managed and AOT targets. The IGatewayClient is the only abstraction; the rest is plain C# + System.Text.Json.

Step 3: the managed shim

EchoWorker.cs (managed csproj only). Extends ApiCommandWorker<Command>. The base class provides Gateway (returns ManagedGatewayAdapter(Api.Gateway)) and HandleCommandAsync (the engine-implemented entry point).

using System.Text.Json.Nodes;
using CloudNative.CloudEvents;
using Virtufin.Worker.DevKit;

namespace Virtufin.Worker.EchoWorker;

public class EchoWorker : ApiCommandWorker<Command>
{
    public EchoWorker()
        : base(source: new Uri("urn:com.virtufin.worker.echoworker"),
               responseEventType: "response.echoworker") { }

    protected override async Task<JsonObject?> HandleCommandAsync(
        CloudEvent input, Command command, JsonNode node)
        => await EchoWorkerLogic.HandleCommandAsync(input, command, node, Gateway);
}

Step 4: the AOT shim

EchoWorkerNative.cs (AOT csproj only). Extends CommandWorker<Command> directly (no ApiCommandWorker, which depends on the managed Virtufin.Api.Client). Overrides Gateway to return the AOT adapter. Exports the C-ABI Process and FreeResult functions via [UnmanagedCallersOnly].

using System.Runtime.InteropServices;
using System.Text.Json.Nodes;
using CloudNative.CloudEvents;
using Virtufin.Worker.DevKit;

namespace Virtufin.Worker.EchoWorker;

/// <summary>
/// AOT shim. <see cref="AotNative{T}"/> sets
/// <c>CurrentContext</c> before invoking the [UnmanagedCallersOnly]
/// entry points; AotGatewayAdapter pulls host/port from there.
/// </summary>
public class EchoWorkerNative : CommandWorker<Command>
{
    public EchoWorkerNative()
        : base(source: new Uri("urn:com.virtufin.worker.echoworker"),
               responseEventType: "response.echoworker") { }

    protected override IGatewayClient Gateway
        => new AotGatewayAdapter(AotNative<EchoWorkerNative>.CurrentContext!);

    protected override async Task<JsonObject?> HandleCommandAsync(
        CloudEvent input, Command command, JsonNode node)
        => await EchoWorkerLogic.HandleCommandAsync(input, command, node, Gateway);

    [UnmanagedCallersOnly(EntryPoint = "Process")]
    public static unsafe int Process(
        IntPtr host, IntPtr inBuf, int inLen, IntPtr outBuf, IntPtr outLen)
        => AotNative<EchoWorkerNative>.ProcessStatic(
            host, inBuf, inLen, (IntPtr*)outBuf, (int*)outLen);

    [UnmanagedCallersOnly(EntryPoint = "FreeResult")]
    public static void FreeResult(IntPtr result)
        => AotNative<EchoWorkerNative>.FreeResultStatic(result);
}

Step 5: the two csproj files

Managed csproj (Virtufin.Worker.EchoWorker.csproj)

Inherits the same shape as the WebSocketManagerController managed csproj: <PackageId>Virtufin.Worker.EchoWorker</PackageId>, <PackageReference Include="Virtufin.Worker.DevKit" />, <Compile Include="EchoWorkerLogic.cs" Link="Logic.cs"/>, PackageWorker MSBuild target.

AOT csproj (Virtufin.Worker.EchoWorker.Native.csproj)

The shared EchoWorkerLogic.cs is included via relative path: <Compile Include="..\Virtufin.Worker.EchoWorker.Managed\EchoWorkerLogic.cs" Link="Logic.cs"/>. The AOT-specific flags (<PublishAot>true</PublishAot>, <RuntimeIdentifiers>linux-x64;linux-arm64</RuntimeIdentifiers>, per-RID <ObjCopyName>) are all included.

The AssemblyName for both csprojs is the same string (EchoWorker) — the on-disk filename the WM matches against virtufinLibrary. The AOT publish output (EchoWorkerNative.so) is renamed to libEchoWorker.so at build time per NuGet convention.

Step 6: build and verify

# Managed-only build
python3 scripts/build_managed.py
python3 scripts/publish.py

# AOT build (requires Docker)
python3 scripts/build_aot.py
python3 scripts/publish.py

# Smoke test the AOT nupkg
python3 scripts/smoke_test_aot.py

See also

  • Engines — DotNetDll vs NativeDll, and the IGatewayClient adapter pattern in detail
  • Packaging Modes — the three packaging modes and which to pick
  • Build Script — the build_aot.py + build_managed.py + build_both.sh reference