Operations
This page covers operational concerns for running MicroDCS in production: configuration reference, backpressure behaviour across the processing pipeline, graceful shutdown, observability, and capacity planning.
Deployment Model
A MicroDCS deployment has four moving parts:
flowchart LR
subgraph Pod
API["Sidecar container\n(e.g. FastAPI)"] -- "MessagePack-RPC\nlocalhost:8888" --> DCS["MicroDCS container\nasync event loop"]
DCS --- Redis[(Redis)]
end
DCS -- "MQTT v5" --> Broker[MQTT Broker]
The MicroDCS container runs a single async event loop. All protocol handlers, bindings, and processors share this loop — isolation between them is achieved through bounded async queues, not separate threads or processes. Understanding where those queues sit and what happens when they fill is the foundation for operating the system reliably.
Redis Requirements
Redis is the system of record: job orders and states, SFC execution state, work and change streams, and the deduplication keys all live there.
Version and modules. Use Redis 8 or Redis Stack. The code relies on JSON documents, search
indexes (FT.*), streams and Lua scripts. Managed Redis offerings without the JSON and Search
modules do not work. Redis Cluster is not a supported topology: the Lua scripts touch several
keys, and the search indexes span key prefixes.
Persistence baseline. Run Redis with an append-only file and a persistent volume:
The default Redis configuration only snapshots (save 3600 1 300 100 60 10000) and keeps no
AOF. After a crash it restarts from the last snapshot and silently loses every write since,
including job orders the MES was told were stored. With appendfsync everysec at most about one
second of writes can be lost. Mount the data directory (/data in the official image) on a
persistent volume, otherwise a rescheduled pod starts empty.
A single Redis with this baseline is a reasonable starting point. A restart takes seconds, and the design already tolerates much longer MES outages (see High availability). Replication with Sentinel or an operator is only needed if even a short pause in sequence control is not acceptable. Replication is asynchronous, so a failover can still lose the last writes.
What a Redis outage does. A lost Redis connection raises an error in the publisher or the
SFC engine loop, which cancels the whole task group and exits the process. Kubernetes restarts
the pod, and startup fails until Redis answers (the connection is checked once at startup), so
replicas crash-loop with increasing back-off. On restart _recovery_scan resumes active jobs.
Incoming MQTT messages that were not yet acknowledged are redelivered by the broker because
the persistent session is kept. A message that was being processed at the crash is claimed
again once its dedupe lease expires (APP_MQTT_DEDUPE_LEASE_SECONDS), so it is processed at
least once. There is no in-process Redis reconnection.
Recovery Objectives
These are design characteristics derived from the implementation and its tests. They are not measured service levels, so measure them in your own deployment before committing to a target.
| Event | Data loss (recovery point) | Time to recover |
|---|---|---|
Redis process crash, AOF everysec on a persistent volume |
About 1 second of writes at most | Redis restart time, then the replicas restart (see below) |
| Redis process crash, default configuration (snapshots only) | Everything since the last snapshot, up to an hour for a single change | As above |
| Redis graceful stop and start | None | As above |
| Redis failover to an asynchronous replica | The last writes not yet replicated | Failover time, then the replicas restart |
| One processor pod dies | None: unacknowledged MQTT messages are redelivered and work items are re-claimed | XAUTOCLAIM re-delivers its SFC work after 30 seconds idle; other replicas keep serving |
| Publisher pod dies | None | Pod restart (10 to 30 seconds); retained topics bridge the gap |
| Push-command response delivered to another replica | None | Until the next restart re-dispatches the command (see SFC Engine) |
After a Redis outage every replica exits and Kubernetes restarts it with growing back-off, so the
recovery time is the Redis downtime plus pod startup plus the current back-off delay (Kubernetes
caps it at five minutes). Messages are delivered at least once: a message in flight at a crash is
processed again after its dedupe lease expires (APP_MQTT_DEDUPE_LEASE_SECONDS, 30 seconds by
default), and commands to equipment can be repeated.
Configuration Reference
All settings are read from environment variables with the prefix APP_ and nested
structure APP_{SECTION}_{FIELD}.
Redis (APP_REDIS_*)
| Variable | Type | Default | Description |
|---|---|---|---|
APP_REDIS_HOSTNAME |
str |
localhost |
Redis server hostname |
APP_REDIS_PORT |
int |
6379 |
Redis server port |
APP_REDIS_KEY_PREFIX |
str |
microdcs |
Prefix applied to all Redis keys |
APP_REDIS_USERNAME |
str |
None |
Redis ACL username (optional) |
APP_REDIS_PASSWORD |
str |
None |
Redis password (optional). Prefer APP_REDIS_PASSWORD_FILE; setting both is an error |
APP_REDIS_PASSWORD_FILE |
Path |
None |
File containing the Redis password, for example a mounted Kubernetes secret. A trailing newline is ignored |
APP_REDIS_SSL |
bool |
false |
Enable TLS for Redis connection |
APP_REDIS_SSL_CA_CERTS |
Path |
None |
CA certificate for Redis TLS (optional) |
APP_REDIS_SOCKET_TIMEOUT |
float |
10.0 |
Seconds before a Redis read or write times out; must exceed the longest blocking stream read (2 s) |
APP_REDIS_SOCKET_CONNECT_TIMEOUT |
float |
5.0 |
Seconds before opening a Redis connection times out |
APP_REDIS_SOCKET_KEEPALIVE |
bool |
true |
Enable TCP keep-alive on Redis connections |
APP_REDIS_HEALTH_CHECK_INTERVAL |
int |
30 |
Seconds of idleness after which a pooled connection is checked with PING before reuse (0 disables) |
MQTT (APP_MQTT_*)
| Variable | Type | Default | Description |
|---|---|---|---|
APP_MQTT_HOSTNAME |
str |
localhost |
MQTT broker hostname |
APP_MQTT_PORT |
int |
1883 |
MQTT broker port |
APP_MQTT_IDENTIFIER |
str |
app_client |
MQTT client identifier |
APP_MQTT_SAT_TOKEN_PATH |
Path |
/var/run/secrets/tokens/broker-sat |
Path to SAT token for broker auth |
APP_MQTT_TLS_CERT_PATH |
Path |
/var/run/certs/ca.crt |
CA certificate for TLS connections |
APP_MQTT_REQUIRE_TLS |
bool |
false |
Fail at startup if the CA certificate is missing instead of connecting in plaintext |
APP_MQTT_USERNAME |
str |
None |
MQTT username (optional) |
APP_MQTT_PASSWORD |
str |
None |
MQTT password (optional). Prefer APP_MQTT_PASSWORD_FILE; setting both is an error |
APP_MQTT_PASSWORD_FILE |
Path |
None |
File containing the MQTT password, for example a mounted Kubernetes secret. A trailing newline is ignored |
APP_MQTT_TLS_CLIENT_CERT_PATH |
Path |
None |
Client certificate for mutual TLS; requires APP_MQTT_TLS_CLIENT_KEY_PATH and TLS to be on |
APP_MQTT_TLS_CLIENT_KEY_PATH |
Path |
None |
Private key for the client certificate |
APP_MQTT_MESSAGE_WORKERS |
int |
5 |
Concurrent tasks processing incoming messages |
APP_MQTT_DEDUPE_TTL_SECONDS |
int |
600 |
How long Redis remembers a processed message (seconds) |
APP_MQTT_DEDUPE_LEASE_SECONDS |
int |
30 |
Lease a worker holds on a message while processing it; must exceed the slowest handler. An expired lease lets another worker process the message again |
APP_MQTT_BINDING_OUTGOING_QUEUE_SIZE |
int |
5 |
Per-binding outgoing queue capacity |
APP_MQTT_SESSION_EXPIRY_INTERVAL |
int |
4294967295 |
MQTT v5 session expiry interval in seconds (2³²−1 = never expire) |
MessagePack RPC (APP_MSGPACK_*)
| Variable | Type | Default | Description |
|---|---|---|---|
APP_MSGPACK_HOSTNAME |
str |
localhost |
RPC server listen address |
APP_MSGPACK_PORT |
int |
8888 |
RPC server listen port |
APP_MSGPACK_TLS_CERT_PATH |
Path |
/var/run/certs/ca.crt |
CA certificate used to verify client certificates (with TLS_CLIENT_AUTH) |
APP_MSGPACK_TLS_SERVER_CERT_PATH |
Path |
/var/run/certs/tls.crt |
Server certificate; TLS is enabled when this file exists |
APP_MSGPACK_TLS_SERVER_KEY_PATH |
Path |
/var/run/certs/tls.key |
Private key for the server certificate |
APP_MSGPACK_REQUIRE_TLS |
bool |
false |
Fail at startup if the server certificate or key is missing instead of listening in plaintext |
APP_MSGPACK_TLS_CLIENT_AUTH |
bool |
false |
Require a client certificate; implies REQUIRE_TLS |
APP_MSGPACK_KEEP_ALIVE |
bool |
true |
Enable TCP keep-alive on client connections |
APP_MSGPACK_MAX_QUEUED_CONNECTIONS |
int |
100 |
TCP backlog for the RPC server socket |
APP_MSGPACK_MAX_CONCURRENT_REQUESTS |
int |
10 |
Per-client concurrent RPC request cap (semaphore) |
APP_MSGPACK_MAX_BUFFER_SIZE |
int |
8388608 |
Max msgpack unpacker buffer (8 MB) |
APP_MSGPACK_BINDING_OUTGOING_QUEUE_SIZE |
int |
5 |
Per-binding outgoing queue capacity |
Processing (APP_PROCESSING_*)
| Variable | Type | Default | Description |
|---|---|---|---|
APP_PROCESSING_OTEL_INSTRUMENTATION_ENABLED |
bool |
false |
Use OTEL-instrumented handlers at runtime |
APP_PROCESSING_CLOUDEVENT_SOURCE |
str |
None |
CloudEvent source attribute for outgoing events |
APP_PROCESSING_MESSAGE_EXPIRY_INTERVAL |
int |
None |
Default message expiry interval (seconds) |
APP_PROCESSING_SHARED_SUBSCRIPTION_NAME |
str |
None |
MQTT shared subscription group name |
APP_PROCESSING_TOPIC_PREFIXES |
set[str] |
∅ |
Comma-separated name:prefix pairs for topic routing |
APP_PROCESSING_TOPIC_WILDCARD_LEVELS |
set[str] |
∅ |
Comma-separated name:levels pairs for subscription wildcards |
APP_PROCESSING_TOPIC_DISCRIMINATORS |
set[str] |
∅ |
Comma-separated name:discriminator pairs — inserts a fixed path segment before the intent (e.g. a schema version) |
APP_PROCESSING_RESPONSE_TOPICS |
set[str] |
∅ |
Comma-separated response topic names |
APP_PROCESSING_SHUTDOWN_GRACE_PERIOD |
int |
30 |
Seconds to wait for in-flight work during shutdown |
APP_PROCESSING_BINDING_OUTGOING_QUEUE_MAX_SIZE |
int |
1000 |
Global upper cap on any binding's outgoing queue |
APP_PROCESSING_POST_START_LOCK_TTL |
int |
30 |
TTL (seconds) for the distributed post_start Redis lock; controls how long a crashed instance blocks other replicas |
APP_PROCESSING_ENFORCE_SUBJECT_TOPIC_MATCH |
bool |
true |
Drop incoming MQTT messages whose CloudEvent subject does not match the scope in the topic (see Security) |
Logging (APP_LOGGING_*)
| Variable | Type | Default | Description |
|---|---|---|---|
APP_LOGGING_LEVEL |
str |
INFO |
Root log level |
APP_LOGGING_FILENAME |
str |
None |
Also write logs to this file (overwritten at start). Off by default so the container can run with a read-only root filesystem; logs go to stdout and stderr |
APP_LOGGING_FORMAT |
str |
%(asctime)s - %(name)s - %(levelname)s - %(message)s |
Log record format |
APP_LOGGING_LOG_PAYLOADS |
bool |
false |
Include message payloads in DEBUG logs and in deserialization errors. Off by default because payloads can contain production data; the VS Code run tasks turn it on |
APP_LOGGING_DISABLE_IF_OTEL_ENABLED |
bool |
true |
Leave logging to OpenTelemetry when OTEL_LOGS_EXPORTER is set |
MQTT Topic Structure
MicroDCS derives all subscribe and publish topics automatically from the processing configuration. Understanding the topic layout helps when integrating with external MQTT clients, debugging message routing, or planning namespace partitioning across multiple deployed processors.
Topic Anatomy
Subscribe and publish topics share the same structural pattern, but they differ in what fills the wildcard/subject position:
Subscribe topics (derived at binding creation, fixed for the lifetime of the connection):
Publish topics (derived per outgoing message when mqtt_path_from_subject=True):
| Segment | Source | Required |
|---|---|---|
{prefix} |
APP_PROCESSING_TOPIC_PREFIXES — the prefix part of a name:prefix entry |
Yes |
/+...N |
Single-level wildcards, one per wildcard level (APP_PROCESSING_TOPIC_WILDCARD_LEVELS) — subscribe only |
No |
/{subject-path} |
CloudEvent subject attribute, dots replaced with / — publish only, when mqtt_path_from_subject=True |
No |
/{discriminator} |
APP_PROCESSING_TOPIC_DISCRIMINATORS — the discriminator part of a name:discriminator entry |
No |
/{intent} |
Message intent: data, events, commands, or metadata |
Yes |
Examples with prefix factory/line1, discriminator v2, wildcard level 1, and subject station.A:
| Context | Resulting topic |
|---|---|
| Subscribe, no discriminator | factory/line1/events, factory/line1/+/events |
| Subscribe, with discriminator | factory/line1/v2/events, factory/line1/+/v2/events |
| Publish, no discriminator | factory/line1/station/A/commands |
| Publish, with discriminator | factory/line1/station/A/v2/commands |
Processor Binding Direction
The processor's binding direction (NORTHBOUND or SOUTHBOUND, set by the
@processor_config decorator) determines which intents are subscribed to and which
are published on:
| Direction | Subscribes to | Publishes to |
|---|---|---|
SOUTHBOUND |
data, events, metadata |
commands |
NORTHBOUND |
commands |
data, events, metadata |
A southbound processor typically represents DCS-side logic that reacts to equipment telemetry and issues commands back. A northbound processor represents MES-side logic that issues commands down to the DCS and receives confirmations and telemetry back up.
Wildcard Subscriptions
APP_PROCESSING_TOPIC_WILDCARD_LEVELS controls how many single-level + wildcards
are appended between the prefix and the intent when subscribing. This allows a single
binding to receive messages from multiple topic subtrees — for example, across several
stations under a shared line prefix.
With name:N where N is the number of wildcard levels, the binding subscribes to:
{prefix}/{intent} # level 0 (always included)
{prefix}/+/{intent} # level 1
{prefix}/+/+/{intent} # level 2
...
{prefix}/+/...+/{intent} # level N
If a discriminator is configured, it is inserted before {intent} at every level:
Response Topics
APP_PROCESSING_RESPONSE_TOPICS configures the backchannel topic used for
request-reply patterns (e.g. command acknowledgement). The actual subscribed response
topic is {response_topic_base}/{processor_instance_id}, making it unique per
processor instance.
When a processor publishes a COMMAND intent message, the MQTT binding automatically
sets the MQTT v5 ResponseTopic property to this response topic, so the downstream
device or service knows where to send its reply.
Shared Subscriptions
APP_PROCESSING_SHARED_SUBSCRIPTION_NAME wraps every subscribe topic in an MQTT v5
shared subscription group:
This distributes incoming messages across all instances in the group (round-robin at the broker), enabling horizontal scaling without duplicate delivery. The response topic is never wrapped in a shared subscription — replies must arrive at the specific instance that issued the command.
Discriminators and Namespace Partitioning
APP_PROCESSING_TOPIC_DISCRIMINATORS inserts a fixed segment immediately before the
intent in both subscribe and publish topics. Its primary use cases are:
- Schema versioning — e.g. discriminator
v2separates messages with a new payload schema from the existingv1traffic on the same prefix without requiring a prefix rename. - Application partitioning — e.g. discriminator
sfcvsqaon the same line prefix routes different application concerns to different processors.
At binding creation time, MicroDCS checks that no two processors registered against
the same ProcessingConfig share an identical prefix + discriminator combination.
If they do, a warning is logged — this is not an error because intentional fan-out
routing (delivering the same message to multiple processors) is a supported pattern.
WARNING: Processors 'jobs' and 'greetings' share the same topic routing key
'factory/line1'. Incoming messages matching this key will be
delivered to both processors.
Configuration Example
The following shows a complete environment variable setup for two processors sharing the same line prefix but separated by discriminator:
# Two processors, same line prefix, different discriminators
APP_PROCESSING_TOPIC_PREFIXES=jobs:factory/line1,greetings:factory/line1
APP_PROCESSING_TOPIC_WILDCARD_LEVELS=jobs:1,greetings:1
APP_PROCESSING_TOPIC_DISCRIMINATORS=jobs:machinery-jobs,greetings:greetings
APP_PROCESSING_RESPONSE_TOPICS=jobs:factory/responses,greetings:factory/responses
Results for the jobs processor (SOUTHBOUND, subscribes to data/events/metadata):
Subscribe: factory/line1/machinery-jobs/data
factory/line1/machinery-jobs/events
factory/line1/machinery-jobs/metadata
factory/line1/+/machinery-jobs/data
factory/line1/+/machinery-jobs/events
factory/line1/+/machinery-jobs/metadata
Publish: factory/line1/{subject-path}/machinery-jobs/commands
Response: factory/responses/{instance-id}
Backpressure
Queue Boundaries
The pipeline from an incoming MQTT message to a dispatched processor, and from a processor response to a published outgoing message, crosses several bounded queues. Each queue has a distinct fill condition and a distinct consequence when full.
| Queue | Config variable | Default | Fills when | Producer behaviour when full |
|---|---|---|---|---|
| MQTT binding outgoing | APP_MQTT_BINDING_OUTGOING_QUEUE_SIZE |
5 |
A single binding's outgoing events accumulate faster than the handler drains them | Raises RuntimeError — the producer is not blocked, the error propagates to the caller |
| MessagePack binding outgoing | APP_MSGPACK_BINDING_OUTGOING_QUEUE_SIZE |
5 |
Outgoing notification frames queue faster than connected clients consume them | Raises RuntimeError — same behaviour as MQTT binding queues |
| MessagePack concurrent requests | APP_MSGPACK_MAX_CONCURRENT_REQUESTS |
10 |
More than N simultaneous publish RPC calls arrive from the same client |
Server stops reading from the socket — TCP-level backpressure to the client |
Global queue cap
All per-binding queue sizes are subject to APP_PROCESSING_BINDING_OUTGOING_QUEUE_MAX_SIZE
(default 1000). If a protocol-level setting exceeds this cap, it is silently reduced
to the cap value with a warning log. If the protocol-level setting is 0, the cap value
is used as the effective size.
Isolation
The binding-level queues (APP_MQTT_BINDING_OUTGOING_QUEUE_SIZE,
APP_MSGPACK_BINDING_OUTGOING_QUEUE_SIZE) are per-binding instances. A slow or
saturated binding does not affect other bindings — a tightening controller processor
that falls behind does not block a QA camera processor from publishing its results.
Tuning Signals
Before a queue fills, it will show up as latency — the time between an MQTT message
arriving and the processor dispatching it, or between a processor returning a response
and the outgoing publish completing. Monitor these latencies under load to establish
a baseline. A latency spike that precedes a queue-full RuntimeError is the signal
to act.
APP_MQTT_MESSAGE_WORKERS (default 5) controls how many concurrent asyncio tasks
process incoming MQTT messages. This is the most important concurrency knob — if
processing latency is high but the event loop is not saturated, increasing workers
allows more messages to be dispatched in parallel.
APP_MSGPACK_MAX_CONCURRENT_REQUESTS is both a backpressure mechanism and a
resource bound. If a sidecar is legitimately sending bursts above this limit, increase
the limit before increasing queue sizes — the concurrent request cap is the first gate.
APP_MSGPACK_MAX_QUEUED_CONNECTIONS (default 100) caps the TCP backlog for
incoming sidecar connections. If multiple sidecar replicas connect simultaneously at
startup, connections beyond this limit are refused at the kernel level.
APP_MQTT_DEDUPE_TTL_SECONDS (default 600) controls how long Redis remembers
processed message IDs. After a broker reconnect, messages replayed within this window
are deduplicated. If your broker's session expiry exceeds this TTL, you may see
duplicate processing after reconnects.
APP_MQTT_DEDUPE_LEASE_SECONDS (default 30) is how long a worker owns a message
while it is being processed. If the worker dies, the message becomes available again
when the lease expires, so a redelivered message waits at most this long before it is
processed. Set it above the slowest message handler: a handler that outlives the lease
lets a second worker process the same message concurrently.
Redis operation latency is a secondary signal: slow await on DAO calls inside a
processor handler holds the worker for that message and reduces effective throughput
even if the queues are not full.
Known Failure Modes
MQTT broker unreachable during a burst
The MQTT handler retries with backoff. Messages that were produced by processors and
placed on the binding outgoing queue during the disconnected period accumulate there.
Because binding queues use put_nowait(), once full they raise RuntimeError rather
than blocking. Operators should monitor broker connectivity and alert before the
reconnect window exceeds the time it takes to fill the binding outgoing queue at peak
publish rate.
Redis connection loss
Processor handlers that persist state via DAOs (JobOrderAndStateDAO,
WorkMasterDAO, JobResponseDAO, etc.) do so inside the handler coroutine. If Redis
becomes unreachable, DAO calls raise connection errors that propagate up and fail the
message processing for that event. The MQTT handler does not retry failed messages —
unacknowledged QoS 1 messages will be redelivered by the broker. Monitor Redis
connectivity and command latency as part of pre-startup health checks and runtime
alerting.
Redis slow under load
A Redis latency spike translates directly into a reduction in effective
message_workers throughput, because workers are occupied waiting on Redis while MQTT
messages accumulate on the incoming queue. This is distinct from a full connection loss
— operations complete, but slowly.
Sidecar RPC client faster than the DCS event loop
The APP_MSGPACK_MAX_CONCURRENT_REQUESTS cap prevents a fast sidecar (e.g. a
FastAPI container receiving a burst of HTTP requests) from overwhelming the MicroDCS
event loop with simultaneous publish RPC calls. If the sidecar regularly hits this
cap, the right response is to increase the cap and profile the processors being
called — a processor that holds the loop for a long time under load is the root cause,
not the cap itself.
Graceful Shutdown
When the process receives SIGTERM or SIGINT, the SystemEventTaskGroup initiates
graceful shutdown:
- Signal received — no new messages are accepted from protocol handlers.
- Grace period starts — controlled by
APP_PROCESSING_SHUTDOWN_GRACE_PERIOD(default30seconds). - In-flight work completes — message workers finish processing their current message, including any pending Redis DAO operations.
- Expiration tasks cancel — outstanding CloudEvent expiration timers are cancelled without triggering expiry callbacks.
- Binding queues drain — outgoing messages already enqueued are published.
- Handler connections close — MQTT client disconnects cleanly; MessagePack RPC server stops accepting new connections and closes existing ones.
- Process exits — if work does not complete within the grace period, remaining tasks are cancelled and the process exits.
In Kubernetes, ensure the pod's terminationGracePeriodSeconds exceeds
APP_PROCESSING_SHUTDOWN_GRACE_PERIOD by at least a few seconds to allow for signal
propagation delay.
Observability
MicroDCS emits OpenTelemetry traces and metrics through OTELInstrumented handler
variants. This section documents what the instrumentation emits, how to interpret it
operationally, and how to get started with a working setup.
Signal Inventory
MQTT Handler (OTELInstrumentedMQTTHandler)
| Signal | Type | Measures | Key Attributes |
|---|---|---|---|
process MQTT {topic} |
Span (CONSUMER) | Time from message receipt to processor dispatch completion | messaging.system, messaging.operation.type, messaging.destination.name, messaging.mqtt.qos, messaging.message.id |
messaging.process.counter |
Counter | Total MQTT messages processed | messaging.system, messaging.destination.name, status (success/error) |
messaging.process.duration |
Histogram | MQTT message processing time (milliseconds) | messaging.system, messaging.destination.name, status (success/error) |
MessagePack Handler (OTELInstrumentedMessagePackHandler)
| Signal | Type | Measures | Key Attributes |
|---|---|---|---|
{rpc.method} |
Span (CONSUMER) | Time from RPC call receipt to response | rpc.system, rpc.service, rpc.method, network.transport, server.address, server.port |
rpc.server.call.count |
Counter | Total MessagePack RPC calls processed | rpc.system, rpc.service, rpc.method, status (success/error) |
rpc.server.call.duration |
Histogram | RPC call processing time (milliseconds) | rpc.system, rpc.service, rpc.method, status (success/error) |
All signal names and attributes follow OpenTelemetry Semantic Conventions.
Manual instrumentation
There is currently no OpenTelemetry auto-instrumentation library for aiomqtt.
The OTELInstrumentedMQTTHandler and OTELInstrumentedMessagePackHandler provide
manual instrumentation that wraps the handler's message processing and RPC dispatch
loops. This means OTEL signals cover application-level processing but not low-level
MQTT client operations (connect, subscribe, ping). If an auto-instrumentation
library for aiomqtt becomes available in the future, it can be layered on top.
Operational Questions
Is the system healthy?
A healthy system shows:
messaging.process.durationhistogram completing within the expected command → response round-trip budget for the station. At ISA-95 Level 2, a typical tightening command cycle is O(seconds); a QA camera pull event may be O(tens of seconds). Establish baselines permessaging.destination.nameduring commissioning.messaging.process.counterwithstatus=errorat zero or near-zero. A rising error count indicates processor exceptions or infrastructure failures.- Span error rate low and stable. A sudden spike in errored spans after a deployment indicates a regression in processor logic.
Is something slow?
Look at the messaging.process.duration histogram broken down by
messaging.destination.name (topic). A latency increase on a specific topic without
a corresponding infrastructure issue points to processor logic. A latency increase
that affects all topics simultaneously points to the event loop being held — check for
a processor handler that does synchronous I/O or CPU-bound work without await.
For RPC calls, rpc.server.call.duration broken down by rpc.method provides the
same signal for the MessagePack path.
Is something stuck?
Symptoms of a stuck system:
messaging.process.counterrate drops to zero while the broker reports queued messages — indicates the handler is not consuming.- RPC spans show increasing duration without completing — the processor may be blocked on an external dependency (Redis, equipment).
- Kubernetes liveness probe failures — if the event loop is blocked, health endpoints stop responding.
Getting Started
To enable OTEL instrumentation, set APP_PROCESSING_OTEL_INSTRUMENTATION_ENABLED=true
and configure the standard OpenTelemetry environment variables for your collector:
APP_PROCESSING_OTEL_INSTRUMENTATION_ENABLED=true
OTEL_SERVICE_NAME=microdcs.app
OTEL_TRACES_EXPORTER=otlp # or "console" for local debugging
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
In the application wiring, register both the plain and instrumented handler variants.
The framework selects the instrumented variant at runtime when
otel_instrumentation_enabled is true:
from microdcs.mqtt import MQTTHandler, OTELInstrumentedMQTTHandler
from microdcs.msgpack import MessagePackHandler, OTELInstrumentedMessagePackHandler
microdcs.register_protocol_handler(
MQTTHandler(...),
OTELInstrumentedMQTTHandler(...),
)
microdcs.register_protocol_handler(
MessagePackHandler(...),
OTELInstrumentedMessagePackHandler(...),
)
The non-instrumented handler variants (MQTTHandler, MessagePackHandler) remain
available for local development where an OTEL collector is not present.
Alert Recommendations
The following alert thresholds provide a starting point. Tune them during commissioning based on observed baselines.
| Condition | Suggested threshold | Rationale |
|---|---|---|
messaging.process.duration p95 |
> 2× baseline for 5 min | Processing is degrading; investigate before queues fill |
messaging.process.counter{status=error} rate |
> 0 sustained for 2 min | Processor exceptions need immediate attention |
rpc.server.call.duration p95 |
> 2× baseline for 5 min | Sidecar-facing latency is rising |
| Redis command latency p95 | > 50 ms for 5 min | DAO calls are slowing down workers |
| MQTT broker disconnection | Any occurrence | Outgoing events will queue; reconnect should complete before queue fills |
| Pod restart count | > 2 in 10 min | Crash loop — check logs for startup failures |
Capacity Planning
Sizing Message Workers
APP_MQTT_MESSAGE_WORKERS (default 5) determines how many messages can be
processed concurrently. The effective throughput is:
For example, with 5 workers and 200 ms average processing time (including Redis round-trips): 5 / 0.2 = 25 messages/s. If your peak arrival rate exceeds this, either increase workers or reduce processing latency.
Sizing Binding Outgoing Queues
The binding outgoing queue must absorb bursts where a processor produces events faster than the handler can publish them. Size it to cover the burst duration:
The default of 5 is conservative. For processors that emit multiple outgoing events
per incoming event (e.g. fan-out patterns), increase proportionally.
Remember that APP_PROCESSING_BINDING_OUTGOING_QUEUE_MAX_SIZE (default 1000) is the
hard upper cap — any per-protocol value above this is silently reduced.
Sizing MQTT Shared Queues
For production deployments where the defaults of 0 (unbounded) are not acceptable:
- Incoming queue: size to accommodate
burst_duration × arrival_rate. A queue of 100–500 handles typical industrial burst profiles where equipment reports in batches. - Outgoing queue: size based on peak processor output rate and publish latency. Usually smaller than incoming since publish is fast when the broker is healthy.
MessagePack Concurrency
APP_MSGPACK_MAX_CONCURRENT_REQUESTS (default 10) should match the sidecar's
expected parallelism. If the sidecar spawns up to N concurrent HTTP handlers that each
call the RPC server, set this to at least N. Going higher than needed wastes no
resources (it's a semaphore, not pre-allocated).