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
contextActorActivationContextworkManagerWorkManager
Properties
Id
Gets the current actor id.
protected override ActorId Id { get; }
Property Value
State
Gets the actor state accessor for the current activation.
protected override IActorStateAccessor State { get; }
Property Value
Methods
CreateAsync(CreateWorkerPayload, CancellationToken)
public Task CreateAsync(CreateWorkerPayload body, CancellationToken cancellationToken = default)
Parameters
bodyCreateWorkerPayloadcancellationTokenCancellationToken
Returns
DeleteAsync(CancellationToken)
public Task DeleteAsync(CancellationToken cancellationToken = default)
Parameters
cancellationTokenCancellationToken
Returns
GetHistoryAsync(CancellationToken)
public Task<HistoryPayload> GetHistoryAsync(CancellationToken cancellationToken = default)
Parameters
cancellationTokenCancellationToken
Returns
GetTagAsync(string, CancellationToken)
public Task<string?> GetTagAsync(string key, CancellationToken cancellationToken = default)
Parameters
keystringcancellationTokenCancellationToken
Returns
GetTagsAsync(CancellationToken)
public Task<TagsPayload> GetTagsAsync(CancellationToken cancellationToken = default)
Parameters
cancellationTokenCancellationToken
Returns
LoadCodeAsync(LoadCodePayload, CancellationToken)
public Task LoadCodeAsync(LoadCodePayload body, CancellationToken cancellationToken = default)
Parameters
bodyLoadCodePayloadcancellationTokenCancellationToken
Returns
OnActivateAsync(CancellationToken)
Runs when the actor activation starts.
protected override ValueTask OnActivateAsync(CancellationToken cancellationToken)
Parameters
cancellationTokenCancellationToken
Returns
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
cancellationTokenCancellationToken
Returns
SetTagAsync(SetTagPayload, CancellationToken)
public Task SetTagAsync(SetTagPayload body, CancellationToken cancellationToken = default)
Parameters
bodySetTagPayloadcancellationTokenCancellationToken
Returns
SetTagsAsync(Dictionary<string, string>, CancellationToken)
public Task SetTagsAsync(Dictionary<string, string> tags, CancellationToken cancellationToken = default)
Parameters
tagsDictionary<string, string>cancellationTokenCancellationToken
Returns
StartAsync(CancellationToken)
public Task StartAsync(CancellationToken cancellationToken = default)
Parameters
cancellationTokenCancellationToken
Returns
StopAsync(CancellationToken)
public Task StopAsync(CancellationToken cancellationToken = default)
Parameters
cancellationTokenCancellationToken
Returns
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
cancellationTokenCancellationToken