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
runtime, falling back to the first lib/ group if none match.
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: Do not include
CloudNative.CloudEvents.dllin the package to avoid type identity conflicts with the WorkManager's own CloudEvent type. TheVirtufin.Worker.DevKit.dllis automatically resolved by the engine host and does not need to be in the package.
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",
"topic": "commands.my-worker",
"group": "my-worker-group"
}' | 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. Include all dependency .dll files in the ZIP. The engine's AssemblyLoadContext.Resolving handler resolves transitive dependencies by assembly name from the ZIP contents.
However, do not include these assemblies in the ZIP:
| Assembly | Reason |
|---|---|
CloudNative.CloudEvents.dll |
Type identity conflict with the WorkManager's copy |
Virtufin.Worker.DevKit.dll |
Resolved from the engine's copy via the Resolving handler |
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 |
| Dependency DLL missing from ZIP | Resolving handler returns null, assembly load fails |
| Assembly load fails | Caught and swallowed during pre-load phase |
Performance
| Phase | Duration |
|---|---|
LoadCodeAsync (ZIP extraction + assembly load) |
< 100ms typical |
Per-message ProcessAsync invocation |
< 1ms |