Table of Contents

Class WorkerActor

Namespace
Virtufin.WorkManager.Actors
Assembly
Virtufin.WorkManager.dll

One actor instance per worker, keyed by worker id. Dapr's placement service guarantees at most one live activation of a given ActorId cluster-wide -- that guarantee, not a hand-rolled InstanceId directory + heartbeat + forward-or-reclaim pattern (Virtufin.Service.MultiInstance, removed by this migration -- see its Virtufin.Service 0.1.0 CHANGELOG entry), is what now ensures every RPC for a worker reaches the one process that actually has it loaded (engine + topic subscription).

A thin router, not a state owner: every method delegates straight to the pre-existing WorkManager, which still does the real work (engine load, topic subscribe/unsubscribe, persisted-config read/write via IDaprConnectionRepository's workmanager equivalent) exactly as before. This mirrors the one part of two prior attempts (this repo's own 2026 actor attempt, and websocketmanager's) that was never the problem -- reusing existing business logic rather than duplicating it in the actor.

Unlike the 2026 attempt, every RPC is routed through the actor, including GetTagAsync(string, CancellationToken)/GetTagsAsync(CancellationToken)/ GetHistoryAsync(CancellationToken) (dropped last time because the actor's own state channel was never wired to WorkerContractsJsonContext's polymorphic CodeSource serialization -- fixed here by reusing that exact context for the actor's wire serializer too, see WorkerActorSerialization).

OnActivateAsync(CancellationToken) self-heals a worker reactivated on an instance that never loaded it -- a genuine failover/rebalance. Reuses the existing RecoverSingleWorkerOnDemandAsync, the same method the old reclaim path already had, just triggered by actor activation instead of a failed-forward fallback branch.

CreateAsync(CreateWorkerPayload, CancellationToken) is actor-routed too -- an earlier version of this migration left worker creation as a plain, non-actor call (like a WebSocket connection, reasoning went, a worker has no external resource that needs pinning at create time). That reasoning was wrong in practice: if Dapr's placement later activated the worker's actor on a different instance than the one that ran a non-actor create, the creating instance's own in-memory copy became a permanently-orphaned duplicate -- confirmed live to make ListWorkersAsync resurrect a genuinely deleted worker forever, whenever a later list call happened to land back on that orphaned instance (not just wasted memory, a real correctness bug). Routing CreateAsync through the actor closes this at the root, the same way ConnectAsync does for websocketmanager: whichever instance Dapr picks for a fresh id's first activation is where the worker actually gets materialized, by construction.

[DaprActor("Worker")]
public sealed class WorkerActor : Actor, IWorkerActor, IActor
Inheritance
WorkerActor
Implements
Inherited Members

Constructors

WorkerActor(ActorActivationContext, WorkManager)

One actor instance per worker, keyed by worker id. Dapr's placement service guarantees at most one live activation of a given ActorId cluster-wide -- that guarantee, not a hand-rolled InstanceId directory + heartbeat + forward-or-reclaim pattern (Virtufin.Service.MultiInstance, removed by this migration -- see its Virtufin.Service 0.1.0 CHANGELOG entry), is what now ensures every RPC for a worker reaches the one process that actually has it loaded (engine + topic subscription).

A thin router, not a state owner: every method delegates straight to the pre-existing WorkManager, which still does the real work (engine load, topic subscribe/unsubscribe, persisted-config read/write via IDaprConnectionRepository's workmanager equivalent) exactly as before. This mirrors the one part of two prior attempts (this repo's own 2026 actor attempt, and websocketmanager's) that was never the problem -- reusing existing business logic rather than duplicating it in the actor.

Unlike the 2026 attempt, every RPC is routed through the actor, including GetTagAsync(string, CancellationToken)/GetTagsAsync(CancellationToken)/ GetHistoryAsync(CancellationToken) (dropped last time because the actor's own state channel was never wired to WorkerContractsJsonContext's polymorphic CodeSource serialization -- fixed here by reusing that exact context for the actor's wire serializer too, see WorkerActorSerialization).

OnActivateAsync(CancellationToken) self-heals a worker reactivated on an instance that never loaded it -- a genuine failover/rebalance. Reuses the existing RecoverSingleWorkerOnDemandAsync, the same method the old reclaim path already had, just triggered by actor activation instead of a failed-forward fallback branch.

CreateAsync(CreateWorkerPayload, CancellationToken) is actor-routed too -- an earlier version of this migration left worker creation as a plain, non-actor call (like a WebSocket connection, reasoning went, a worker has no external resource that needs pinning at create time). That reasoning was wrong in practice: if Dapr's placement later activated the worker's actor on a different instance than the one that ran a non-actor create, the creating instance's own in-memory copy became a permanently-orphaned duplicate -- confirmed live to make ListWorkersAsync resurrect a genuinely deleted worker forever, whenever a later list call happened to land back on that orphaned instance (not just wasted memory, a real correctness bug). Routing CreateAsync through the actor closes this at the root, the same way ConnectAsync does for websocketmanager: whichever instance Dapr picks for a fresh id's first activation is where the worker actually gets materialized, by construction.

public WorkerActor(ActorActivationContext context, WorkManager workManager)

Parameters

context ActorActivationContext
workManager WorkManager

Properties

Id

Gets the current actor id.

protected override ActorId Id { get; }

Property Value

ActorId

State

Gets the actor state accessor for the current activation.

protected override IActorStateAccessor State { get; }

Property Value

IActorStateAccessor

Methods

CreateAsync(CreateWorkerPayload, CancellationToken)

public Task CreateAsync(CreateWorkerPayload body, CancellationToken cancellationToken = default)

Parameters

body CreateWorkerPayload
cancellationToken CancellationToken

Returns

Task

DeleteAsync(CancellationToken)

public Task DeleteAsync(CancellationToken cancellationToken = default)

Parameters

cancellationToken CancellationToken

Returns

Task

GetHistoryAsync(CancellationToken)

public Task<HistoryPayload> GetHistoryAsync(CancellationToken cancellationToken = default)

Parameters

cancellationToken CancellationToken

Returns

Task<HistoryPayload>

GetTagAsync(string, CancellationToken)

public Task<string?> GetTagAsync(string key, CancellationToken cancellationToken = default)

Parameters

key string
cancellationToken CancellationToken

Returns

Task<string>

GetTagsAsync(CancellationToken)

public Task<TagsPayload> GetTagsAsync(CancellationToken cancellationToken = default)

Parameters

cancellationToken CancellationToken

Returns

Task<TagsPayload>

LoadCodeAsync(LoadCodePayload, CancellationToken)

public Task LoadCodeAsync(LoadCodePayload body, CancellationToken cancellationToken = default)

Parameters

body LoadCodePayload
cancellationToken CancellationToken

Returns

Task

OnActivateAsync(CancellationToken)

Runs when the actor activation starts.

protected override ValueTask OnActivateAsync(CancellationToken cancellationToken)

Parameters

cancellationToken CancellationToken

Returns

ValueTask

OnDeactivateAsync(CancellationToken)

Runs when Dapr ends this instance's activation for the worker -- idle timeout, or (confirmed live) placement moving it to a different instance, including transiently, e.g. a retried call landing elsewhere after this instance's first attempt hit a transient failure. Without this, the worker stayed in this instance's local registry forever once no longer authoritative here -- see EvictLocalAsync(Guid, CancellationToken)'s doc comment for the exact bug this closes (virtufin/common#9, Phase 2).

protected override ValueTask OnDeactivateAsync(CancellationToken cancellationToken)

Parameters

cancellationToken CancellationToken

Returns

ValueTask

SetTagAsync(SetTagPayload, CancellationToken)

public Task SetTagAsync(SetTagPayload body, CancellationToken cancellationToken = default)

Parameters

body SetTagPayload
cancellationToken CancellationToken

Returns

Task

SetTagsAsync(Dictionary<string, string>, CancellationToken)

public Task SetTagsAsync(Dictionary<string, string> tags, CancellationToken cancellationToken = default)

Parameters

tags Dictionary<string, string>
cancellationToken CancellationToken

Returns

Task

StartAsync(CancellationToken)

public Task StartAsync(CancellationToken cancellationToken = default)

Parameters

cancellationToken CancellationToken

Returns

Task

StopAsync(CancellationToken)

public Task StopAsync(CancellationToken cancellationToken = default)

Parameters

cancellationToken CancellationToken

Returns

Task

TouchAsync(CancellationToken)

No-op invocation whose only purpose is resetting Dapr's per-actor idle timer. Message dispatch for a running worker happens entirely through WorkManager's topic-subscription pool, never through an actor method call -- deliberately, per this actor's class doc comment -- so a worker that's actively processing messages can still look idle from the actor runtime's point of view. WorkerHeartbeatService's periodic call here is what keeps such a worker's actor from being idle-deactivated (and, on a later invocation, reactivated on a different instance -- double-loading and double-subscribing the same worker, not just a stale read) while it's genuinely still running here. See the app-wide ActorIdleTimeout in Program.cs for the backstop this is meant to make practically unreachable, not relied on.

public Task TouchAsync(CancellationToken cancellationToken = default)

Parameters

cancellationToken CancellationToken

Returns

Task