DotNet DLL Workers
The DotNetDllEngine (in-process, default as of
LIBRARY_VERSION 0.0.59) loads pre-compiled .NET assemblies
into an isolated AssemblyLoadContext inside the
AOT-compiled WorkManager. Workers must implement the
IWorker interface provided by the Virtufin.Worker.DevKit
NuGet package.
Scope:
Virtufin.Worker.DevKitis specifically for the DotNet DLL engine. It is not used by the PythonEngine, CSharpSourceEngine, or any other engine.See In-Process DotNet DLL Workers for the architecture and trade-offs.
See Worker DevKit for the full hierarchy and codec-driven API contract.
Quick Start
1. Create a Worker Project
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>MyWorker</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CloudNative.CloudEvents" Version="2.8.0" />
<PackageReference Include="Virtufin.Worker.DevKit" Version="*" />
</ItemGroup>
</Project>
2. Implement the Worker
Option A: Extend CommandWorker (Recommended)
CommandWorker<T> (JSON convenience over
CommandWorker<TInput, TOutput, TCommand>) parses the CloudEvent
data as JSON, extracts the "command" field, and dispatches to
HandleCommandAsync. The handler returns a typed payload
(JsonObject); the base class wraps it in the CloudEvent envelope
with Id, Type, Source, correlationid, and time:
using System.Text.Json.Nodes;
using CloudNative.CloudEvents;
using Virtufin.Worker.DevKit;
public class MyWorker : CommandWorker<MyCommand>
{
public MyWorker() : base(new Uri("urn:my-worker"), "my.response") { }
protected override Task<JsonObject?> HandleCommandAsync(
CloudEvent input, MyCommand command, JsonNode node)
=> command switch
{
MyCommand.hello => Task.FromResult<JsonObject?>(
Response(input, command.ToString(), true, message: "world")),
_ => Task.FromResult<JsonObject?>(null),
};
}
public enum MyCommand { hello, goodbye }
Returning null from the handler signals "no reply"; returning an
error payload uses ErrorPayload(message). The base class catches
handler exceptions and wraps them as an error envelope.
Option B: Extend ApiWorker (no commands)
For workers that don't use a command field — extend
WorkerBase<JsonNode, JsonObject> via the ApiWorker JSON
convenience and override HandleAsync(CloudEvent, JsonNode):
using System.Text.Json.Nodes;
using CloudNative.CloudEvents;
using Virtufin.Worker.DevKit;
public class MyWorker : ApiWorker
{
public MyWorker() : base(new Uri("urn:my-worker"), "my.response") { }
protected override Task<JsonObject?> HandleAsync(
CloudEvent input, JsonNode node)
{
var payload = node.ToJsonString();
return Task.FromResult<JsonObject?>(
Response(input, "process", true, message: $"Processed: {payload}"));
}
}
Option C: Implement IWorker Directly
For full control, implement IWorker and skip the DevKit
hierarchy. You build the CloudEvent envelope yourself:
using CloudNative.CloudEvents;
using Virtufin.Worker.DevKit;
public class MinimalWorker : IWorker
{
public Task<CloudEvent?> ProcessAsync(CloudEvent input)
{
return Task.FromResult<CloudEvent?>(new CloudEvent
{
Type = "my.response",
Source = new Uri("urn:my-worker"),
DataContentType = "application/json",
Data = "{\"status\":\"ok\"}"
});
}
}
3. Build and Package
The worker is shipped as a standard NuGet .nupkg so the engine can
read its assembly list, target framework, and metadata via
NuGet.Packaging. Use dotnet pack with NuspecProperties to inject
the (optional) virtufin* extensions:
dotnet pack -c Release \
-o . \
-p:PackageId=Virtufin.Worker.MyWorker \
-p:PackageVersion=0.0.1 \
-p:NuspecProperties="virtufinAbiVersion=1;virtufinEntryPoint=Process;virtufinFreeResult=FreeResult"
The <id>.dll (your worker assembly) is placed under lib/<tfm>/ by
dotnet pack (e.g. lib/net10.0/Virtufin.Worker.MyWorker.dll). The
DotNetDll engine host picks the entry whose TFM best matches its own
You can also pack manually if you need finer control — just produce
the same <id>.nuspec + lib/<tfm>/<id>.dll layout and zip it into
a .nupkg.
Important: third-party dependencies (including
Virtufin.Worker.DevKit) belong in<dependencies>, resolved at worker-load time — not vendored intolib/<tfm>/.dotnet packalready does the right thing by default from yourPackageReferences. See "Dependency Resolution" below for the full guidance and the one exception (CloudNative.CloudEventsshould stay an indirect dependency, not a directPackageReference).
4. Deploy to WorkManager
WORKER_ID=$(curl -s -X POST "http://localhost:5001/workers" \
-H "Content-Type: application/json" \
-d '{
"mimeType": "application/x-dotnet-dll",
"topics": ["commands.my-worker"],
}' | jq -r '.id')
curl -s -X PUT "http://localhost:5001/workers/$WORKER_ID/code" \
-H "Content-Type: application/octet-stream" \
--data-binary @worker.nupkg
curl -s -X POST "http://localhost:5001/workers/$WORKER_ID/start"
Discovery
The engine scans all assemblies loaded in the isolated AssemblyLoadContext for a non-abstract, non-interface type implementing Virtufin.Worker.DevKit.IWorker.
If exactly one implementation is found:
- Instance method (ProcessAsync is not static): A single instance is created via Activator.CreateInstance(). State is preserved across invocations (engine-specific, not shared between workers).
- Static method: No instance is created. The method is invoked directly.
If no IWorker implementation is found, an InvalidOperationException is thrown with:
No IWorker implementation found in the worker assembly. Implement Virtufin.Worker.DevKit.IWorker on a public, non-abstract class.
No other discovery mechanism is used. Method-name scanning, convention matching, and attribute-based discovery have been replaced by the IWorker contract.
WorkerBase Helpers
WorkerBase<TInput, TOutput> is the generic root. JSON workers use
WorkerBase<JsonNode, JsonObject> (via ApiWorker /
CommandWorker<T>); non-JSON workers plug in their own
ICloudEventCodec<TInput, TOutput>. Common members:
| Member | Description |
|---|---|
Constructor (source, responseEventType, codec?) |
Sets the CloudEvent.Source and CloudEvent.Type used in all responses. The JSON convenience base classes pre-bake JsonCloudEventCodec. |
Codec : ICloudEventCodec<TInput, TOutput> |
The codec instance that owns payload decode/encode. |
ProcessAsync(CloudEvent) |
Interface implementation. Not sealed — subclasses override the typed ProcessAsync(CloudEvent, TInput) instead. |
BuildResponse(input, TOutput payload) |
Wraps the typed payload in a CloudEvent envelope with the worker's Source/Type and auto-applies correlationid (from input) and time. |
BuildResponse(input, object? data, string contentType) |
Overload for non-typed payloads (used by Error(...)). |
Error(message) |
Shortcut for BuildResponse(input, codec.EncodeError(message)) — produces the standard {command:"error", success:false, message} payload for JSON. |
CorrelationIdAttribute, ReplyTopicAttribute |
Const strings for the canonical extension attribute names. |
CloudEvent.WithCorrelationId(input) |
Fluent extension — copies the correlationid extension from input. The base class applies this automatically. |
CloudEvent.WithTimestamp(this, offset?) |
Fluent extension — sets the time extension. The base class applies this automatically. |
CommandWorker
CommandWorker<TInput, TOutput, TCommand> extends
WorkerBase<TInput, TOutput> with typed command dispatch. The
JSON convenience CommandWorker<T> fixes
TInput = JsonNode, TOutput = JsonObject, and pre-bakes
JsonCloudEventCodec.
| Member | Description |
|---|---|
HandleCommandAsync(CloudEvent, TCommand, TInput) → Task<TOutput?> |
Abstract — override to implement command handlers. Return the typed payload (JsonObject? for the JSON convenience); the base class wraps it in the envelope. Return null for unknown commands (no reply published). |
ExtractCommand(CloudEvent, TInput) → TCommand |
Virtual — default impl reads data.command and Enum.TryParse<T>(...). Override to change the wire format. |
Response(input, command, success, id?, message?) |
Convenience (JSON convenience only) — builds a standard JsonObject {command, success, id?, message?}. Propagates correlationid + time via the base envelope builder. |
ErrorPayload(message) |
Convenience (JSON convenience only) — builds the standard error JsonObject. |
The base class handles JSON parsing, command extraction, error wrapping, and correlation ID + timestamp propagation — the override only contains the dispatch logic.
ApiWorker and ApiCommandWorker
ApiWorker : WorkerBase<JsonNode, JsonObject> (JSON-specific) provides
an ApiClient for backend service calls via the API Gateway.
ApiCommandWorker<T> : CommandWorker<T> extends that with typed command
dispatch.
ApiWorker
| Member | Description |
|---|---|
Constructor (source, responseEventType, apiHost?, apiPort?) |
Initializes the base WorkerBase<JsonNode, JsonObject> and (optionally) creates an ApiClient at apiHost:apiPort. |
HandleAsync(CloudEvent, JsonNode) → Task<JsonObject?> |
Abstract — override to process events with pre-configured API access. |
Api : ApiClient? |
The configured client for calling backend services; null until EnsureClient(...) is called if the lazy ctor was used. |
using System.Text.Json.Nodes;
using CloudNative.CloudEvents;
using Virtufin.Api.Client;
using Virtufin.Worker.DevKit;
public class MyApiWorker : ApiWorker
{
public MyApiWorker()
: base(new Uri("urn:my-api-worker"), "my.response",
apiHost: "localhost", apiPort: 5002) { }
protected override async Task<JsonObject?> HandleAsync(
CloudEvent input, JsonNode node)
{
var result = await Api!.Gateway.targetservice.SomeMethodAsync(
new Dictionary<string, object?> { ["key"] = "value" });
return Response(input, "process", true, message: $"Result: {result}");
}
}
ApiCommandWorker
| Member | Description |
|---|---|
Constructor (source, responseEventType, apiHost?, apiPort?) |
Initializes the base CommandWorker<T> and (optionally) ApiClient. |
HandleCommandAsync(CloudEvent, T, JsonNode) → Task<JsonObject?> |
Abstract — same as CommandWorker<T>, with Api available. |
Api : ApiClient? |
The configured client for calling backend services. |
FieldApiHost, DefaultApiPort = 5002 |
Constants — api_host field name and default gateway port. |
EnsureClient(host, port) / EnsureClientFromCommand(node) |
Lazy helpers — call from the handler if you didn't pre-bake the host/port in the ctor. |
using System.Text.Json.Nodes;
using CloudNative.CloudEvents;
using Virtufin.Worker.DevKit;
public class MyApiCommandWorker : ApiCommandWorker<MyCommand>
{
public MyApiCommandWorker()
: base(new Uri("urn:my-api-command-worker"), "my.response",
apiHost: "localhost", apiPort: 5002) { }
protected override async Task<JsonObject?> HandleCommandAsync(
CloudEvent input, MyCommand command, JsonNode node)
{
var result = await Api!.Gateway.targetservice
.DispatchAsync(command.ToString(), node);
return Response(input, command.ToString(), true, message: result?.ToString());
}
}
public enum MyCommand { create, list, delete }
Migration: Manual ApiClient to ApiCommandWorker
Before (manual ApiClient):
public class MyWorker : CommandWorker
{
private ApiClient _client;
public MyWorker() : base("my.response", new Uri("urn:my-worker"))
{
_client = new ApiClient("localhost", 5002);
}
protected override async Task<CloudEvent?> HandleCommandAsync(
CloudEvent input, string command, JsonNode node)
{
var result = await _client.InvokeAsync("backend", command, node);
return Response(input, command, true, message: result?.ToString());
}
}
After (ApiCommandWorker
public class MyWorker : ApiCommandWorker<MyCommand>
{
public MyWorker()
: base(new Uri("urn:my-worker"), "my.response",
apiHost: "localhost", apiPort: 5002) { }
protected override async Task<JsonObject?> HandleCommandAsync(
CloudEvent input, MyCommand command, JsonNode node)
{
var result = await Api!.Gateway.backend
.DispatchAsync(command.ToString(), node);
return Response(input, command.ToString(), true, message: result?.ToString());
}
}
public enum MyCommand { create, list, delete }
Dependency Resolution
Worker assemblies may depend on third-party NuGet packages. Don't
vendor dependency .dll files into the nupkg — declare them as
normal PackageReferences in your worker's .csproj instead;
dotnet pack (or any standard NuGet packaging flow) already writes
them into the nuspec's <dependencies> element automatically. The
nupkg's lib/<tfm>/ should contain only your own worker assembly.
At load time, NuGetDependencyInstaller
(Virtufin.WorkManager.Engine.DotNetDll.Bridge) reads the nuspec's
declared <dependencies> and resolves + downloads them itself, via
NuGet.Protocol/NuGet.Resolver driven in-process — against a
configurable public source (DOTNET_DEFAULT_NUGET_SOURCE_URL, default
nuget.org) and, for Virtufin.* packages, the private virtufin Gitea
feed. This adds real network latency to the first LoadCodeAsync
call for a given worker (subsequent loads within a pod's lifetime hit
a local cache); see the DotNetDllEngine section of engines.md for
the full design.
Virtufin.Worker.DevKit should be a normal, direct
PackageReference — declaring it is exactly what lets
NuGetDependencyInstaller resolve it at load time. At runtime it
still ends up aliased to the host's own already-loaded copy (not a
freshly-downloaded one) for type-identity reasons — downloading it too
is harmless, just slightly wasted work.
Do not add a direct PackageReference to
CloudNative.CloudEvents in your worker's own .csproj — let it come
in transitively through Virtufin.Worker.DevKit's own declared
dependency (NuGetDependencyInstaller walks the full transitive
graph, so it'll be resolved regardless). A direct reference risks
pinning a different version than DevKit expects, which can produce a
type identity conflict with the WorkManager's own copy.
Error Handling
| Scenario | Behavior |
|---|---|
No IWorker implementation |
InvalidOperationException on LoadCodeAsync |
| Worker throws exception | Caught by WorkerBase.ProcessAsync, wrapped as codec.EncodeError(...) envelope, logged as error |
ProcessAsync called before LoadCodeAsync |
InvalidOperationException |
| Declared dependency fails to resolve/download | NuGetDependencyInstaller throws before the assembly load is even attempted |
| Assembly load fails | Caught and swallowed during pre-load phase |
Performance
| Phase | Duration |
|---|---|
LoadCodeAsync, dependencies already cached |
< 100ms typical (ZIP extraction + assembly load) |
LoadCodeAsync, cold NuGet dependency cache |
seconds, network-bound (NuGetDependencyInstaller resolving + downloading declared dependencies -- see "Dependency Resolution" above) |
Per-message ProcessAsync invocation |
< 1ms |