Files
scadalink-design/AkkaDotNet/19-AkkaIO.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

19 — Akka.IO (TCP/UDP Networking)

Overview

Akka.IO provides actor-based, non-blocking TCP and UDP networking built into the core Akka library. Instead of working with raw sockets, you interact with special I/O manager actors that handle connection lifecycle, reading, and writing through messages. This fits naturally into the actor model — connection state is managed by actors, and data flows through the mailbox.

In the SCADA system, Akka.IO is a candidate for implementing the custom legacy protocol adapter. If the custom protocol runs over raw TCP sockets (not HTTP, not a managed library), Akka.IO provides the connection management layer.

When to Use

  • Implementing the custom legacy SCADA protocol adapter if it communicates over raw TCP or UDP sockets
  • Building any custom network protocol handler that needs actor-based lifecycle management
  • When you need non-blocking I/O integrated with the actor supervision model (automatic reconnection on failure)

When Not to Use

  • OPC-UA communication — use an OPC-UA client library (e.g., OPC Foundation's .NET Standard Stack), not raw sockets
  • HTTP communication — use HttpClient or ASP.NET Core
  • If the custom protocol has its own managed .NET client library — use that library instead, wrapping it in an actor
  • High-throughput bulk data transfer — Akka.Streams with custom stages may be more appropriate for backpressure handling

Design Decisions for the SCADA System

Custom Protocol Actor with Akka.IO TCP

If the custom legacy protocol is a proprietary TCP-based protocol, model each device connection as an actor that uses Akka.IO:

public class CustomProtocolConnectionActor : ReceiveActor
{
    private readonly EndPoint _remoteEndpoint;
    private IActorRef _connection;

    public CustomProtocolConnectionActor(DeviceConfig config)
    {
        _remoteEndpoint = new DnsEndPoint(config.Hostname, config.Port);
        Become(Disconnected);
    }

    private void Disconnected()
    {
        // Request a TCP connection
        Context.System.Tcp().Tell(new Tcp.Connect(_remoteEndpoint));

        Receive<Tcp.Connected>(connected =>
        {
            _connection = Sender;
            _connection.Tell(new Tcp.Register(Self));
            Become(Connected);
        });

        Receive<Tcp.CommandFailed>(failed =>
        {
            // Connection failed — schedule retry
            Context.System.Scheduler.ScheduleTellOnce(
                TimeSpan.FromSeconds(5), Self, new RetryConnect(), ActorRefs.NoSender);
        });

        Receive<RetryConnect>(_ => Become(Disconnected)); // Re-triggers connect
    }

    private void Connected()
    {
        Receive<Tcp.Received>(received =>
        {
            // Parse the custom protocol frame from received.Data
            var frame = CustomProtocolParser.Parse(received.Data);
            HandleFrame(frame);
        });

        Receive<SendCustomCommand>(cmd =>
        {
            var bytes = CustomProtocolSerializer.Serialize(cmd);
            _connection.Tell(Tcp.Write.Create(ByteString.FromBytes(bytes)));
        });

        Receive<Tcp.ConnectionClosed>(closed =>
        {
            _connection = null;
            Become(Disconnected);
        });
    }
}

Frame Parsing and Buffering

Industrial protocols often use framed messages (length-prefixed or delimited). TCP delivers data as a byte stream, so you must handle partial reads and frame reassembly:

private ByteString _buffer = ByteString.Empty;

private void HandleReceived(Tcp.Received received)
{
    _buffer = _buffer.Concat(received.Data);

    while (TryParseFrame(_buffer, out var frame, out var remaining))
    {
        _buffer = remaining;
        ProcessFrame(frame);
    }
}

private bool TryParseFrame(ByteString data, out CustomFrame frame, out ByteString remaining)
{
    // Check if we have a complete frame (e.g., length prefix + payload)
    if (data.Count < 4)
    {
        frame = null;
        remaining = data;
        return false;
    }

    var length = BitConverter.ToInt32(data.Take(4).ToArray(), 0);
    if (data.Count < 4 + length)
    {
        frame = null;
        remaining = data;
        return false;
    }

    frame = CustomFrame.Parse(data.Slice(4, length));
    remaining = data.Slice(4 + length);
    return true;
}

Connection Supervision

Wrap the connection actor in a parent that supervises reconnection:

// Parent actor's supervision strategy
protected override SupervisorStrategy SupervisorStrategy()
{
    return new OneForOneStrategy(
        maxNrOfRetries: -1,  // Unlimited retries
        withinTimeRange: TimeSpan.FromMinutes(1),
        decider: Decider.From(
            Directive.Restart,  // Restart on any exception — re-establishes connection
            (typeof(SocketException), Directive.Restart)
        ));
}

Akka.IO vs. Direct Socket Wrapper

If the custom protocol client already has a managed .NET library, wrapping it in an actor (without Akka.IO) is simpler:

public class CustomProtocolDeviceActor : ReceiveActor
{
    private readonly CustomProtocolClient _client;  // Existing library

    public CustomProtocolDeviceActor(DeviceConfig config)
    {
        _client = new CustomProtocolClient(config.Hostname, config.Port);
        _client.OnTagChanged += (tag, value) =>
            Self.Tell(new TagValueReceived(tag, value));

        ReceiveAsync<ConnectToDevice>(async _ => await _client.ConnectAsync());
        Receive<TagValueReceived>(HandleTagUpdate);
    }
}

Use Akka.IO only if you need actor-level control over the TCP connection lifecycle, or if no managed client library exists.

Common Patterns

Tag Subscription via Polling or Push

If the custom protocol supports push-based tag subscriptions (the device sends updates when values change), the connection actor receives Tcp.Received messages passively. If polling is required, use the scheduler:

// Polling pattern
Context.System.Scheduler.ScheduleTellRepeatedly(
    TimeSpan.FromSeconds(1),
    TimeSpan.FromSeconds(1),
    Self,
    new PollTags(),
    ActorRefs.NoSender);

Receive<PollTags>(_ =>
{
    foreach (var tag in _subscribedTags)
    {
        var request = CustomProtocolSerializer.CreateReadRequest(tag);
        _connection.Tell(Tcp.Write.Create(ByteString.FromBytes(request)));
    }
});

Backpressure with Ack-Based Writing

For high-throughput writes, use Akka.IO's ack-based flow control:

_connection.Tell(Tcp.Write.Create(data, ack: new WriteAck()));

Receive<WriteAck>(_ =>
{
    // Previous write completed — safe to send next
    SendNextQueuedCommand();
});

Anti-Patterns

Blocking Socket Operations in Actors

Never use synchronous socket calls (Socket.Receive, Socket.Send) inside an actor. This blocks the dispatcher thread. Akka.IO handles all I/O asynchronously.

Not Handling Partial Reads

TCP is a stream protocol. A single Tcp.Received message may contain a partial frame, multiple frames, or a frame split across two receives. Always implement frame buffering and parsing.

Creating One TCP Manager Per Device

The TCP manager (Context.System.Tcp()) is a singleton per ActorSystem. Do not create additional instances. Each device actor sends Tcp.Connect to the same TCP manager.

Configuration Guidance

akka.io.tcp {
  # Buffer pool settings — defaults are fine for SCADA scale
  buffer-pool = "akka.io.tcp.disabled-buffer-pool"

  # Maximum number of open channels
  max-channels = 1024  # Sufficient for 500 devices

  # Batch sizes for reads
  received-message-size-limit = 65536  # 64KB per read
  direct-buffer-size = 65536
}

For 500 device connections, the default TCP settings are adequate. Increase max-channels only if you anticipate more concurrent connections.

References