Engines
The WorkManager loads a worker nupkg through one of two in-process engines. The engine is selected at CreateWorker time by the mime_type field: application/x-dotnet-dll → DotNetDllEngine; application/x-native-dll → NativeDllEngine. The worker source ships both shims; the WM picks one per worker instance.
The contrast
DotNetDllEngine (managed) |
NativeDllEngine (AOT) |
|
|---|---|---|
| Base class in worker | ApiCommandWorker<T> (extends CommandWorker<T>) |
CommandWorker<T> directly |
| Gateway transport | ManagedGatewayAdapter → Virtufin.Api.Client.GatewayClient.InvokeAsync (managed gRPC) |
AotGatewayAdapter → WorkerContext.InvokeGateway (C-ABI function pointer) |
| Load mechanism | AssemblyLoadContext.LoadFromStream(byte[]) + reflection (IWorkerFinder.FindAndInstantiate(assembly)) |
NativeLibrary.Load(tempFile) + NativeLibrary.GetExport(handle, "Process") |
| Engine runtime in the AOT-published WM | The AOT WM embeds CoreCLR via libhostfxr (the DotNetDll.Bridge is a regular IL DLL loaded into CoreCLR) |
The AOT WM's own native process loads the .so via dlopen/dlsym |
| Worker assembly on disk | lib/net10.0/WebSocketManagerController.dll (IL, machine-independent) |
runtimes/<rid>/native/libWebSocketManagerController.so (native ELF, RID-specific) |
| Class discovery | IWorkerFinder.FindAndInstantiate(assembly) — finds the type implementing IWorker.ProcessAsync(CloudEvent) |
NativeLibrary.GetExport(handle, "Process") + GetExport(handle, "FreeResult") — finds the C-ABI exports |
| Per-worker isolation | Each worker in its own collectible AssemblyLoadContext (VirtufinWorker); DevKit/CloudEvents/Api.Client aliased to the host (default ALC, or bridge component context under AOT WM) |
Each worker in its own NativeLibrary.Load handle; no ALC — single global native symbol space per process |
| Worker unload | ALC.Unload() + GC.Collect() (collectible, but GC-timed) |
NativeLibrary.Free() (deterministic; full symbol teardown) |
| Handler dispatch | Direct in-process: await worker.ProcessAsync(cloudEvent) returns Task<JsonObject?> |
C-ABI: Process(host, inBuf, inLen, outBuf, outLen) writes JSON bytes to outBuf, returns 0 on success / non-zero on error |
| Backend call transport | await gateway.websocketmanager.Connect(dictRequest) (strongly-typed dynamic proxy) |
gateway.InvokeAsync("websocketmanager", "Connect", jsonRequestBytes) (C-ABI host callback) |
| Latency | Sub-microsecond (direct method call) | A few microseconds (one FlatBuffers/JSON round-trip + the C-ABI indirection) |
| Crash isolation | One worker's crash can crash the WM process (no managed sandbox) | Stronger statement: a native segfault terminates the host process. There is no sandbox. Native DLLs must be fully trusted. |
The AOT path is faster for many short calls (no managed dynamic dispatch overhead) but at a significant trust cost. The managed path is easier to develop and supports reflection-based patterns (dependency injection, attribute routing, etc.) but has higher per-call cost for tight loops.
The IGatewayClient abstraction
The two engines differ fundamentally in their ABI surface (managed IWorker.ProcessAsync vs. C-ABI Process(host, inBuf, ...)), but they share the same IGatewayClient contract for outbound calls. The worker author writes:
var response = await gateway.InvokeAsync(
"websocketmanager",
"Connect",
new JsonObject { ["url"] = url, ["auto_reconnect"] = true });
— once. The abstraction routes to:
ManagedGatewayAdapter(in the managedApiCommandWorker<T>base class'sGatewayproperty): wrapsVirtufin.Api.Client.GatewayClient.InvokeAsync(service, method, requestData). TherequestDatais aDictionary<string, object?>; the response is too. The adapter converts to/fromJsonObject.AotGatewayAdapter(constructed in the AOT shim'sGatewayproperty override): wrapsWorkerContext.InvokeGateway(service, method, jsonRequestBytes). ThejsonRequestBytesis abyte[]of JSON; the response is too. The adapter serializes/deserializes to/fromJsonObjectviaSystem.Text.Json(AOT-compatible).
The handler body is identical in both targets — only the base class, the Gateway property source, and the entry-point attribute differ.
How the WM's process looks (AOT build)
When the WM is published with <PublishAot>true</PublishAot>, the resulting binary is a single native ELF (e.g. Virtufin.WorkManager). It does not have a CLR runtime inside it. To run managed workers, it:
- Loads
libhostfxr.so(hostfxr) on first managed-worker load. - Calls
hostfxr_initialize_for_runtime_configto spin up a real CoreCLR instance in the same process. - Loads the DotNetDll.Bridge assembly into that CoreCLR — this is a regular managed DLL that contains the actual
DotNetDllEngineimplementation. - For each managed worker load: extracts the worker DLL from the nupkg's
lib/<tfm>/, callsAssemblyLoadContext.LoadFromStreamon it, finds theIWorkerimplementation, dispatchesProcessAsync. - For each native worker load: extracts the
.sofromruntimes/<rid>/native/, callsNativeLibrary.Load, callsGetExport("Process")andGetExport("FreeResult"), dispatches viaAotNative<T>.ProcessStatic.
This is the design that makes the "one WM binary, two worker types" architecture work.
When to pick which
Default to managed (ApiCommandWorker<T> + DotNetDllEngine):
- The worker is in active development (reflection-based debugging, easy attribute routing, hot-reload via
IWorkerFinder). - The worker needs managed dependencies that aren't AOT-portable (most third-party NuGets).
- The latency budget tolerates a few microseconds per call.
Switch to AOT (CommandWorker<T> directly + NativeDllEngine):
- The worker is performance-critical (tight-loop dispatch, sub-µs budget).
- The worker has no un-AOT-portable dependencies.
- The trust model accepts that the worker is fully trusted (the
.soruns in-process with no sandbox). - The AOT compile time and reproducibility is acceptable (the cross-build image is required; the first compile takes 5-10 min via emulation on arm64 hosts).
Use build_both.py when you want a single nupkg supporting either engine: it produces one nupkg with lib/net10.0/*.dll AND runtimes/<rid>/native/lib*.so.
For A/B testing: register managed and AOT workers on different topics (e.g. command.myworker.managed vs command.myworker.aot). Competing consumers mean two workers on the same topic compete — messages go to exactly one instance, so same-topic A/B testing silently drops messages on one side.
For sequential rollout: create the AOT worker on the production topic, verify it handles messages, then delete the managed worker. Only one worker should be active on a given topic at a time.
See also
- Worker Author Guide — the three-file worker pattern in detail
- Packaging Modes — managed / native / both in practice
- Build Script — how
build_aot.pyandbuild_managed.pyproduce the two artifacts