Files
scadalink-design/AkkaDotNet/01-Actors.md
Joseph Doherty de636b908b Add Akka.NET reference documentation
Notes and documentation covering actors, remoting, clustering, persistence,
streams, serialization, hosting, testing, and best practices for the Akka.NET
framework used throughout the ScadaLink system.
2026-03-16 09:08:17 -04:00

7.9 KiB
Raw Permalink Blame History

01 — Actors (Akka Core Library)

Overview

Actors are the foundational building block of the entire SCADA system. Every machine, protocol adapter, alarm handler, and command dispatcher is modeled as an actor. Actors encapsulate state and behavior, communicate exclusively through asynchronous message passing, and form supervision hierarchies that provide automatic fault recovery.

In our system, the actor model provides three critical capabilities: an actor-per-device model where each of the 50500 machines is represented by its own actor instance, a protocol abstraction hierarchy where a common base actor defines the machine comms API and derived actors implement OPC-UA or custom protocol specifics, and supervision trees that isolate failures in individual device connections from the rest of the system.

When to Use

  • Every machine/device should be represented as an actor — this is the core modeling decision
  • Protocol adapters (OPC-UA, custom) should be actors that own their connection lifecycle
  • Command dispatchers, alarm aggregators, and tag subscription managers are all actors
  • Any component that needs isolated state, failure recovery, or concurrent operation

When Not to Use

  • Simple data transformations with no state — use plain functions or Streams stages instead
  • One-off request/response patterns with external HTTP APIs — consider using standard async/await unless the call needs supervision or retry logic
  • Database access layers — wrap in actors only if you need supervision; otherwise inject repository services via DI

Design Decisions for the SCADA System

Actor-Per-Device Model

Each physical machine gets its own actor instance. The actor holds the current known state of the device (tag values, connection status, last command sent, pending command acknowledgments). This provides natural isolation — if communication with Machine #47 fails, only that actor is affected.

/user
  /device-manager
    /machine-001  (OpcUaDeviceActor)
    /machine-002  (CustomProtocolDeviceActor)
    /machine-003  (OpcUaDeviceActor)
    ...

Protocol Abstraction Hierarchy

Define a common message protocol (interface) that all device actors respond to, regardless of the underlying communication protocol. The actor hierarchy looks like:

IDeviceActor (message contract)
  ├── OpcUaDeviceActor    — implements comms via OPC-UA, subscribes to tags
  └── CustomDeviceActor   — implements comms via legacy custom protocol

Both actor types handle the same inbound messages (SubscribeToTag, SendCommand, GetDeviceState, ConnectionLost, etc.) but implement the underlying transport differently. The rest of the system only interacts with the message contract, never with protocol specifics.

Supervision Strategy

The device manager actor supervises all device actors with a OneForOneStrategy:

  • CommunicationException → Restart the device actor (re-establishes connection)
  • ConfigurationException → Stop the actor (broken config won't fix itself on restart)
  • Exception (general) → Restart with backoff (use exponential backoff to avoid reconnection storms)
  • Escalate only if the device manager itself has an unrecoverable error

Message Design

Messages should be immutable records. Use C# record types:

public record SubscribeToTag(string TagName, IActorRef Subscriber);
public record TagValueChanged(string TagName, object Value, DateTime Timestamp);
public record SendCommand(string CommandId, string TagName, object Value);
public record CommandAcknowledged(string CommandId, bool Success, string? Error);
public record GetDeviceState();
public record DeviceState(string DeviceId, ConnectionStatus Status, IReadOnlyDictionary<string, TagValue> Tags);

Common Patterns

Stash for Connection Lifecycle

Device actors may receive commands before the connection is established. Use IWithStash to buffer messages during the connecting phase, then unstash when the connection is ready:

public class OpcUaDeviceActor : ReceiveActor, IWithStash
{
    public IStash Stash { get; set; }

    public OpcUaDeviceActor(DeviceConfig config)
    {
        Connecting();
    }

    private void Connecting()
    {
        Receive<Connected>(msg => {
            UnstashAll();
            Become(Online);
        });
        Receive<SendCommand>(msg => Stash.Stash());
    }

    private void Online()
    {
        Receive<SendCommand>(HandleCommand);
        Receive<ConnectionLost>(msg => Become(Connecting));
    }
}

Become/Unbecome for State Machines

Device actors naturally have states: Connecting, Online, Faulted, Maintenance. Use Become() to switch message handlers cleanly rather than littering if/else checks throughout.

Ask Pattern — Use Sparingly

Prefer Tell (fire-and-forget with reply-to) over Ask (request-response). Ask creates temporary actors and timeouts. Use Ask only at system boundaries (e.g., when an external API needs a synchronous response from a device actor).

Anti-Patterns

Sharing Mutable State Between Actors

Never pass mutable objects in messages. Each actor's state is private. If two actors need the same data, send immutable copies via messages. This is especially critical in the SCADA context — two device actors should never share a connection object or a mutable tag cache.

God Actor

Avoid creating a single "SCADA Manager" actor that handles all devices, alarms, commands, and reporting. Decompose into a hierarchy: device manager → device actors, alarm manager → alarm processors, command queue → command handlers.

Blocking Inside Actors

Never block an actor's message processing thread with synchronous I/O (e.g., Thread.Sleep, synchronous socket reads, .Result on tasks). This stalls the actor's mailbox and can cascade into thread pool starvation. Use ReceiveAsync or PipeTo for async operations:

// WRONG
Receive<ReadTag>(msg => {
    var value = opcClient.ReadValueSync(msg.TagName); // blocks!
    Sender.Tell(new TagValue(value));
});

// RIGHT
ReceiveAsync<ReadTag>(async msg => {
    var value = await opcClient.ReadValueAsync(msg.TagName);
    Sender.Tell(new TagValue(value));
});

Over-Granular Actors

Not everything needs to be an actor. A device actor can internally use plain objects to manage tag dictionaries, parse protocol frames, or compute alarm thresholds. Only promote to a child actor if the component needs its own mailbox, lifecycle, or supervision.

Configuration Guidance

Dispatcher Tuning

For 500 device actors doing I/O-bound work (network communication with equipment), the default dispatcher is usually sufficient. If profiling shows contention, consider a dedicated dispatcher for device actors:

device-dispatcher {
  type = Dispatcher
  throughput = 10
  executor = "fork-join-executor"
  fork-join-executor {
    parallelism-min = 4
    parallelism-factor = 1.0
    parallelism-max = 16
  }
}

Mailbox Configuration

Default unbounded mailbox is fine for most SCADA scenarios. If tag subscription updates are very high frequency (thousands per second per device), consider a bounded mailbox with a drop-oldest strategy to prevent memory growth from stale updates:

bounded-device-mailbox {
  mailbox-type = "Akka.Dispatch.BoundedMailbox, Akka"
  mailbox-capacity = 1000
  mailbox-push-timeout-time = 0s
}

Dead Letter Monitoring

In a SCADA system, dead letters often indicate a device actor has been stopped but something is still trying to send to it. Subscribe to dead letters for monitoring:

system.EventStream.Subscribe<DeadLetter>(monitoringActor);

References