Architecture
The WorkManager is an event-driven worker system built on Dapr that enables you to deploy and manage workers written in different programming languages. Workers subscribe to pub/sub topics, process incoming CloudEvents, and optionally publish responses.
Note: "Core Components" and "Data Flow" below describe the original, single-service design. As of the WorkManager/Runtime split, every worker's Create/Start/Stop/Delete/LoadCode and message processing actually run in a separate
Virtufin.WorkManager.Runtimeservice, via Dapr actors that each own oneIEngineinstance. See WorkManager/Runtime Split: Engines vs. Actors for how the two relate; the sections below still apply to the non-execution RPCs (GetTag(s)/SetTag(s)/GetWorkerHistory/startup recovery), which have not yet moved.
System Overview
flowchart TB
subgraph WM["WorkManager Service"]
W[WorkerManager<br/>Create/Start/Stop/<br/>Delete/Recover]
ER[EngineRegistry<br/>text/x-python<br/>text/x-csharp<br/>...]
WR[WorkerRegistry<br/>W1 W2 ...]
W <--> ER
ER <--> WR
W --> DC[Dapr Client<br/>State / Pub/Sub]
DC <--> DS[Dapr Subscriptions]
DS --> WR
end
DC --> DR[Dapr Runtime]
DR --> SS[(State Store<br/>statestore)]
DR --> PS[(Pub/Sub<br/>pubsub)]
Core Components
WorkManager (Components/WorkManager.cs)
The central orchestrator that manages the entire worker lifecycle. It:
- Creates, starts, stops, and deletes workers
- Manages Dapr subscriptions to pub/sub topics
- Persists worker configurations to the state store for recovery
- Handles message processing and publishes output
- Publishes worker output back to pub/sub topics
Worker (Runtime/Worker.cs)
Represents a single worker instance with:
- Id: Unique identifier (GUID)
- CodeSource: Where the code comes from (URL or inline content)
- MimeType: The content type identifying the language/engine
- Topics: The pub/sub topics to subscribe to (one or more)
- Status: Either
StoppedorRunning - History: Track of all code changes over time
EngineRegistry (Runtime/EngineRegistry.cs)
Maps MIME types to engine implementations. Each engine factory creates new engine instances per worker.
Engine Implementations (Engines/)
All engines implement the IEngine interface:
public interface IEngine
{
Task LoadCodeAsync(byte[] code, CancellationToken cancellationToken = default);
Task<CloudEvent?> ProcessAsync(CloudEvent input, CancellationToken cancellationToken = default);
}
Available Engines:
| Engine | MIME Type | Description |
|---|---|---|
PythonEngine |
text/x-python, application/x-python |
Executes Python source or a packaged wheel via a python3 subprocess (one engine, format auto-detected) |
CSharpSourceEngine |
text/x-csharp |
Compiles and executes C# source code via a subprocess |
DotNetDllEngine |
application/x-dotnet-dll |
Loads pre-compiled .NET assemblies in-process (via an embedded CoreCLR) |
NativeDllEngine |
application/x-native-dll |
Loads per-architecture native shared libraries in-process |
Engine Process Model
Engines fall into two groups by how isolated a worker's code is from the host process:
flowchart TB
subgraph Host["WorkManager / WorkManager.Runtime process (Native AOT)"]
DDE[DotNetDllEngine]
NDE[NativeDllEngine]
SEB["SubprocessEngineBase<br/>(shared lifecycle + Unix-socket IPC)"]
PE["PythonEngine<br/>(source or wheel, auto-detected)"]
CSE[CSharpSourceEngine]
SEB --- PE
SEB --- CSE
end
DDE -->|hostfxr: embedded CoreCLR,<br/>same OS process| BR[DotNetDll.Bridge]
PE -->|spawns| PY1[python3 subprocess]
CSE -->|spawns| CSB[CSharpSource.Bridge subprocess<br/>Roslyn compile + AssemblyLoadContext]
PY1 <-->|Unix socket:<br/>length-prefixed JSON frames| PE
CSB <-->|Unix socket:<br/>length-prefixed JSON frames| CSE
DotNetDllEngine/NativeDllEngine run worker code in-process — a crash there can take the
whole host process down with it. PythonEngine/CSharpSourceEngine both run
worker code in a genuinely separate child OS process instead, via the shared
SubprocessEngineBase (process lifecycle, health-pinging, auto-restart-on-crash, and the wire
protocol are all implemented once, in the base class): a 4-byte length-prefixed JSON frame protocol
over a Unix domain socket, with ready/process/result/ping/pong/shutdown/error frame
types. CSharpSourceEngine's own child process (CSharpSourceEngine.Bridge) is a real, separately
published, JIT-capable (non-AOT) executable — required because compiling C# source with Roslyn and
then dynamically loading/executing the result needs a JIT, which a Native-AOT-published process
does not have.
WorkerRegistry (Runtime/WorkerRegistry.cs)
In-memory storage for active worker instances. Workers are recovered from persistent state on restart.
WorkManager/Runtime Split: Engines vs. Actors
An IEngine implementation (PythonEngine, CSharpSourceEngine, ...) only knows how to load code
and process one CloudEvent at a time — it has no idea what a "worker" is, doesn't persist anything,
and doesn't know about topics. That's deliberate: an engine is a pure execution mechanism, reused
identically whether it's invoked in-process or hosted remotely.
Everything else a worker needs — a stable identity, persisted configuration, topic subscriptions,
Running/Stopped status, and being reachable across pod restarts and reassignments — is the job of a
Dapr actor. As of the WorkManager/Runtime split, each execution mechanism has its own dedicated
actor type running in a separate Virtufin.WorkManager.Runtime service/pod, never a shared generic
"Worker" actor. text/x-python and application/x-python share one actor type (PythonWorker)
since PythonEngine handles both formats itself -- they aren't distinct execution mechanisms, just
two ways of delivering Python code (see the Engine Process Model diagram above):
| MIME Type | Engine | Runtime Actor Type |
|---|---|---|
text/x-python, application/x-python |
PythonEngine |
PythonWorker |
text/x-csharp |
CSharpSourceEngine |
CSharpSourceWorker |
application/x-dotnet-dll |
DotNetDllEngine |
DotnetDllWorker |
application/x-native-dll |
NativeDllEngine |
NativeDllWorker |
Each actor instance (keyed by worker id) owns exactly one engine instance for its lifetime, and delegates to it:
flowchart LR
subgraph Actor["One actor instance, e.g. PythonWorkerActor (per worker id)"]
Eng["PythonEngine<br/>(owned 1:1, does the actual work)"]
Store["RuntimeWorkerStore<br/>(persisted RuntimeWorkerRecord)"]
Pool["RuntimeTopicSubscriptionPool<br/>(topic subscribe/unsubscribe)"]
end
Actor -->|"LoadCodeAsync / ProcessAsync"| Eng
Actor -->|"Save/Load on Create/Start/Stop/Delete"| Store
Actor -->|"Subscribe/Unsubscribe on Start/Stop"| Pool
The gateway (Virtufin.WorkManager) never hosts these actors — it only proxies to them, resolving
the actor type from the worker's mime type (RuntimeActorTypeNames.ForMimeType) for the
execution-affecting RPCs: CreateWorker, StartWorker, StopWorker, DeleteWorker, LoadCode.
Because the Runtime service is a separate process, a crash inside one worker's engine (a bad C#
compile, a Python subprocess dying, a native library segfault) can only take down that Runtime pod —
never the gateway's own query API (ListWorkers, GetWorkerHistory, etc. keep serving).
sequenceDiagram
participant C as Client
participant GW as WorkManager (gateway)
participant RT as WorkManager.Runtime
participant Actor as PythonWorkerActor
participant Eng as PythonEngine (child process)
C->>GW: CreateWorker(mime_type: text/x-python, code, topics)
GW->>GW: RuntimeActorTypeNames.ForMimeType(mime_type) -> PythonWorker
GW->>RT: Dapr actor invoke: IWorkerRuntimeActor.CreateAsync(payload)
RT->>Actor: activate (mint new instance for this worker id)
Actor->>Eng: LoadCodeAsync(code)
Eng-->>Actor: ready
Actor->>Actor: persist RuntimeWorkerRecord (status: Stopped)
Actor-->>GW: ack
GW-->>C: worker id
What stays on the old path, by design: GetTag(s)/SetTag(s)/GetWorkerHistory still go
through the original in-process WorkerActor path described earlier in this document, for every
mime type -- but only as a thin, persisted-config accessor now. Tags and history never touch
execution, so they're meant to stay gateway-local permanently, not a temporary gap. Startup/on-demand
recovery's engine construction and topic subscription, however, are gated off entirely for any
mime type ForMimeType recognizes (a real bug, fixed after the split: recovering a Runtime-routed
worker in-process too was duplicating its engine and subscription) -- recovery only does real
in-process work for a mime type actor routing doesn't recognize.
Because a Dapr actor activates only when something invokes it, a Runtime worker whose topics go
quiet has nothing to reactivate it (no subscription -> no messages -> no invocations): its
subscriptions die with its pod restart or with the Runtime's 24h ActorIdleTimeout, permanently.
The gateway closes that gap with RuntimeWorkerPokeService: it sweeps the persisted catalog
(default every 10 minutes, RUNTIME_WORKER_POKE_INTERVAL_SECONDS to override, plus once on
startup) and calls TouchAsync on each running Runtime-routed worker's actor -- a live actor
gets a no-op, a deactivated one reactivates and its self-heal re-subscribes its topics. The
re-subscription gap after a Runtime pod restart or an idle timeout is thereby bounded by the
poke interval instead of being unbounded.
Data Flow
Worker Creation Flow
- Client calls
CreateWorkerAsync()via gRPC or direct injection - WorkManager validates the MIME type has a registered engine
- Code is fetched (from URL or extracted from content)
- Engine instance is created and code is loaded
- Worker configuration is persisted to Dapr state store
- Worker is added to the in-memory registry in
Stoppedstatus - Client calls
StartWorkerAsync()to subscribe to Dapr topic and set status toRunning
Message Processing Flow
- Dapr delivers a CloudEvent to the subscription handler
- Worker engine processes the input CloudEvent
- If engine returns a response CloudEvent, publish to output topic
Worker Recovery Flow
On service restart:
- The
WorkManagerRecoveryHostedService(BackgroundService) automatically callsRecoverWorkersAsync()during startup — no operator action or gRPC call required. The hosted service acquires a leader lease via the Dapr state store so concurrent instances elect a single recovery coordinator (see Worker Lifecycle: Recovery for the lease semantics). - All worker configurations are loaded from state store
- For each worker:
- Engine is created and code is re-loaded
- Dapr subscription is re-established
- Worker status is set to
Running
Persistence
Worker configurations are stored in the Dapr state store with these keys:
sys.workmanager.worker.index- List of all worker IDssys.workmanager.worker.<worker_id>- Individual worker configurationsys.workmanager-runtime.worker.<worker_id>- The Runtime's worker recordsys.workmanager.recovery.leader- The recovery leader lease
All of them come from naming/virtufin.service.workmanager.yaml (WorkManagerNames); see Naming.
Code Source Types
Workers support two code source types:
CodeSourceUrl: Code fetched from an HTTP(S) URL at worker creation time
{ "url": "https://example.com/worker.py" }
CodeSourceContent: Base64-encoded inline code
{ "content": "aW1wb3J0IGpzb24..." }
Extension Points
Adding a New Engine
- Create a class implementing
IEngine - Register it in
Program.cs:
engineRegistry.Register(
new ContentType("text/x-my-language"),
"MyLanguage",
() => new MyLanguageEngine()
);
Custom Worker Logic
Workers must implement a ProcessAsync(CloudEvent input) function:
Python:
def Process(event):
return {
"type": "com.example.output",
"source": "my-worker",
"data": { "result": event["data"] }
}
C#:
public static CloudEvent ProcessAsync(CloudEvent input)
{
return new CloudEvent
{
Type = "com.example.output",
Source = new Uri("urn:my-worker"),
Data = input.Data
};
}