Skip to content

Grain Topology & Scaling

Consystence runs its real-time signal path as an Orleans grain pipeline. The defining property of that pipeline is what it deliberately does not do: it does not allocate a grain per tag. Grain activation count scales with infrastructure — controllers, shards, devices, sessions — not with the number of tags being polled. A site reading hundreds of thousands of tags activates roughly the same number of grains as one reading a few thousand.

This page describes the stages of that pipeline, how a batch flows from controller to browser, and why the activation budget stays flat as tag counts grow.

The naive approach — one grain per tag — collapses at industrial scale. A single CHPP can carry hundreds of thousands of tags; a grain per tag means hundreds of thousands of activations, hundreds of thousands of timers, and per-tag persistence churn. Worse, every UI update would flow per tag through the system with no coalescing, saturating the SignalR transport.

The batch pipeline replaces per-tag work with batch envelopes. Reads are planned into contiguous blocks, executed by a small fixed set of shard grains, and emitted as a single batch carrying many tag updates. Each downstream device evaluates once per batch, not once per tag. The result is an order-of-magnitude reduction in grain count and message volume.

The path from controller to browser passes through five grain roles. Each is keyed so that activation count is bounded by physical or configured topology, never by tag count.

Grain roleCountScales withResponsibility
Controller connection1 per controllerControllersConnection lifecycle, health, dispatches read plans
Read planner1 per controllerControllersComputes optimal block groupings; emits a versioned read plan
Controller shardConfigured per controllerControllers × shardsExecutes block reads; holds shard-local current values; emits a batch
Device1 per device instanceDevicesEvaluates a batch once; emits the signal changes
Session publisher1 per sessionSessionsSubscribes to device grains; coalesces changes per tick into one SignalR message

The read planner takes the full tag map for a controller and computes a read plan — a set of read blocks distributed across the controller’s shards. Replanning is atomic: a new plan version is validated and distributed, and each shard swaps its assignment while in-flight reads drain.

The shard count is fixed per controller at configuration time, so it is bounded by topology rather than tag count. Each shard keeps an in-memory current-value dictionary, polls its assigned blocks, diffs against the dictionary, and emits only the changed tags as a batch. There are no per-tag timers and no per-tag persistence.

The critical rule is that a device grain runs its evaluation graph exactly once per batch, regardless of how many tags changed. On receiving a batch it filters to the tags this device cares about (a pre-computed set from its binding config); if nothing relevant changed it returns with no cost. Otherwise it applies the relevant updates and evaluates derived signals, state-machine transitions, alarm thresholds, and interlocks in a single pass, then emits changes for only the signals whose values changed.

If a batch carries 200 tag updates and only 3 are relevant to a given pump, the pump evaluates once with those 3 updates applied — not three times, and not 200 times.

Signal changes reach sessions by direct subscription: each session publisher registers with the relevant device grains and is notified when signals change.

Each browser session has one session publisher. It accumulates incoming deltas and, on a coalescing tick, emits a single update per tick — one SignalR message. That message becomes wire patches — semantic-tree updates the browser’s wire renderer applies; see Multi-renderer UI.

flowchart TD
  HW[Controller<br/>ControlLogix over EtherNet/IP]
  CONN[Controller connection<br/>1 per controller]
  PLAN[Read planner<br/>emits a read plan]
  SHARD[Controller shards<br/>block reads, current values]
  DEV[Device grain<br/>evaluate once per batch]
  PUB[Session publisher<br/>coalesce per tick]
  HIST[Historian<br/>PostgreSQL, synced to cloud]
  UI["Wire patches over SignalR<br/>browser wire renderer · CLI"]

  HW -->|EtherNet/IP CIP| CONN
  PLAN -->|read plan| CONN
  CONN -->|shard assignments| SHARD
  SHARD -->|batch: value, ts, quality| DEV
  DEV -->|signal changes| PUB
  DEV -.samples, alarm events.-> HIST
  PUB -->|one message per tick| UI

The browser is not the only consumer. A device whose binding config names a historian also feeds the historian: the device grain maps its signal changes to samples (and alarm transitions to events) and emits them fire-and-forget — never blocking the live path — to a per-device historian grain, which persists to PostgreSQL. On a Site server a reminder-driven sync grain (default 5-minute cadence, cursor-based for exactly-once delivery) pushes sample blocks up to the cloud tier’s ingest grain. Like the rest of the pipeline, this leg scales with devices and historians, not tags.

Every value carries a quality flag, set at the shard and propagated unchanged through batch, device evaluation, and session update so the UI can render trust state honestly:

QualityMeaning
GoodFreshly read and confirmed
UncertainValue validity in doubt — for example a sensor in manual override
CachedLoaded from a snapshot, not yet refreshed by a live read
BadRead failure or comms loss
StaleExceeded its max-age threshold

When a value is recovered from cache rather than a live read, the UI marks it as Cached until a fresh read confirms it, so an operator always knows whether a value is live or recovered.

Activation budget — infrastructure, not tags

Section titled “Activation budget — infrastructure, not tags”

Because reads are sharded per controller and each device evaluates once per batch, the number of active grains is set by the site’s infrastructure — its controllers, devices, and sessions — not by how many tags are polled. Adding tags to an existing device adds no grains and no timers; the activation budget grows only when you add controllers, devices, or connected sessions. SignalR traffic is likewise bounded by sessions × tick rate rather than by tag-changes-per-second. The shape is identical from a single-controller demo to a large site; only the counts move, and they move with infrastructure.

Every grain role exposes typed metrics, aggregated per site. The metrics that matter for scaling — PLC cycle time, raw-batches per second, per-device evaluation time, SignalR messages and payload size per session, and total Orleans activation count — make the central claim measurable: activation count stays linear with infrastructure, not tags.

Cluster topology: multi-silo in the cloud, single-silo by default on-site

Section titled “Cluster topology: multi-silo in the cloud, single-silo by default on-site”

Cloud data cells run as multi-silo Orleans clusters — several server replicas forming one cluster, with cluster membership held in the cell’s PostgreSQL. A site server, by contrast, typically runs as a single silo: an on-prem plant does not need cluster-scale compute, and one silo is the simplest thing to operate offline. The same code runs both shapes, and grains are written to be multi-silo-correct so a deployment scales out without a rewrite.

Cross-server delivery uses an Orleans affinity relay, not a SignalR backplane. A browser’s WebSocket terminates on one silo, but the grain that produces its updates can be placed on any silo; the producing grain hops its push to a relay pinned on the silo that holds the connection, which delivers through that silo’s local hub. There is no Redis or hosted SignalR service in the path — the relay is the delivery mechanism on both cloud and on-prem.

Two design rules keep grains correct across silos:

  • No authoritative in-memory-only state. A grain that holds data in memory is either fully persistent (Orleans persistent state or write-through to PostgreSQL) or regenerable from the database, so a grain rebalancing across silos never loses state.
  • Clock synchronisation. Orleans reminders coordinate through the cluster’s reminder table, so NTP synchronisation is a deployment prerequisite for multi-silo clusters.

Each site runs its own Orleans cluster with hard isolation by design; there are no cross-site grain calls, and sites synchronise to the cloud tier only through dedicated sync grains. See Tenant isolation and the Architecture overview.