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.
Why per-tag grains do not scale
Section titled “Why per-tag grains do not scale”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.
Pipeline stages
Section titled “Pipeline stages”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 role | Count | Scales with | Responsibility |
|---|---|---|---|
| Controller connection | 1 per controller | Controllers | Connection lifecycle, health, dispatches read plans |
| Read planner | 1 per controller | Controllers | Computes optimal block groupings; emits a versioned read plan |
| Controller shard | Configured per controller | Controllers × shards | Executes block reads; holds shard-local current values; emits a batch |
| Device | 1 per device instance | Devices | Evaluates a batch once; emits the signal changes |
| Session publisher | 1 per session | Sessions | Subscribes to device grains; coalesces changes per tick into one SignalR message |
Read planning and sharding
Section titled “Read planning and sharding”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.
Batch evaluation, once per device
Section titled “Batch evaluation, once per device”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.
Fanout and coalescing
Section titled “Fanout and coalescing”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.
Data flow
Section titled “Data flow”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 historian leg
Section titled “The historian leg”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.
Quality travels with the data
Section titled “Quality travels with the data”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:
| Quality | Meaning |
|---|---|
Good | Freshly read and confirmed |
Uncertain | Value validity in doubt — for example a sensor in manual override |
Cached | Loaded from a snapshot, not yet refreshed by a live read |
Bad | Read failure or comms loss |
Stale | Exceeded 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.
Observability
Section titled “Observability”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.
Related
Section titled “Related”- Architecture overview — where the server tier and grain runtime sit in the deployment model
- Multi-renderer UI — how session-publisher output becomes wire patches over SignalR
- PLC communication — the edge-side controller path (PLCnext border gateway)
- AI architecture — the advisory-only model that bounds AI to non-control roles