Production Patterns
Patterns for running neops workers reliably at scale.
Container Deployment
A minimal Dockerfile for a worker:
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --no-dev
COPY . .
CMD ["uv", "run", "neops_worker"]
Key considerations:
- Pin the Python version to match your development environment.
- The worker imports configuration from the project’s config module — ensure it is available in the container image.
- Use
.envfor configuration or inject environment variables via your orchestrator (Docker Compose, Kubernetes). - Exclude test code via
.dockerignorerather than selectiveCOPY.
Docker Compose
services:
worker:
build: .
environment:
URL_BLACKBOARD: http://workflow-engine:3030
DIR_FUNCTION_BLOCKS: ./my_function_blocks
WORKER_NAME: docker-worker-01
restart: unless-stopped
depends_on:
- workflow-engine
Scaling Workers
Horizontal scaling
Run multiple worker instances. Each worker:
- Registers independently with the workflow engine.
- Polls for jobs it can execute (based on registered function blocks).
- Processes one job at a time (
max_workers=1).
The workflow engine distributes jobs across available workers. To handle more concurrent work, add more worker instances.
Specialization
Different workers can register different function block packages:
| Worker | DIR_FUNCTION_BLOCKS |
Handles |
|---|---|---|
config-worker |
./config_blocks |
Config backup, push, compliance |
inventory-worker |
./inventory_blocks |
Discovery, inventory collection |
monitoring-worker |
./monitoring_blocks |
Health checks, SNMP polling |
This lets you scale each concern independently and isolate failures.
Health Monitoring
Heartbeat
The worker sends heartbeats every HEARTBEAT_INTERVAL seconds, also while it drains a
job after a shutdown request. If the workflow engine stops receiving heartbeats for its
offline threshold (6 minutes by default), it marks the worker offline and fails the
in-flight job as worker-offline; it never re-queues a claimed job.
Container healthcheck
The worker removes HEALTH_FILE (default /tmp/neops-worker-alive) when it starts, so
a restarted container cannot report healthy from the previous process’s file. Once it
polls, it touches the file at the start of every poll cycle, after every job, and on
every heartbeat tick. neops_worker_health exits 0 when that file is younger than
--max-age seconds (default: 3 × HEARTBEAT_INTERVAL) and 1 otherwise;
python -m neops_worker_sdk.cli.health is the same check.
The image declares this check as its HEALTHCHECK, so Docker and Docker Compose report
the worker’s health without any extra configuration:
HEALTHCHECK --interval=30s --timeout=10s --start-period=300s --retries=3 \
CMD ["/app/.venv/bin/neops_worker_health"]
To change the timings in Compose, override it (uv run --no-sync neops_worker_health
runs the same check):
services:
worker:
healthcheck:
test: ["CMD", "/app/.venv/bin/neops_worker_health"]
interval: 30s
timeout: 10s
start_period: 300s # >= 2 x STARTUP_RETRY_TIMEOUT: registration can retry for 240 s
retries: 3
The Helm chart (chart/neops-worker) runs the same command as a startupProbe (30 × 10 s,
the same 300 s budget as start_period) and a livenessProbe. Both are on by default and
are configured in values.yaml; set livenessProbe.enabled: false (or
startupProbe.enabled: false) to drop one. There is no readiness probe, since the worker
serves no traffic.
livenessProbe:
enabled: true
exec:
command: ["/app/.venv/bin/neops_worker_health"]
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
The probes need a writable, non-empty HEALTH_FILE. With readOnlyRootFilesystem: true,
mount an emptyDir at /tmp (or point HEALTH_FILE at a writable volume). An empty
HEALTH_FILE disables the file, and the probes then fail on every check.
Before it polls, the worker registers itself and then its function blocks, each retried
for up to STARTUP_RETRY_TIMEOUT seconds (120 by default) while the engine is
unreachable. Keep start_period at or above twice that, or a worker waiting out an engine
restart is reported unhealthy before it ever polls.
The heartbeat tick refreshes the file whether or not the engine answers, so an engine
outage does not make a busy worker look dead: a job that is still running, or a result
the worker keeps re-sending for up to RESULT_PUSH_TIMEOUT, is not lost to a restart.
It does so only for HEALTH_STALL_TIMEOUT seconds (default 720) after the poll loop last
advanced. A job that hangs therefore turns the worker unhealthy once that time has passed
plus --max-age. The file shows that the worker process is advancing; it does not check
that the engine is reachable.
Who acts on unhealthy
Plain Docker Compose only reports the health status; it never restarts an unhealthy
container (restart: reacts to the process exiting). Restarting on unhealthy needs
an orchestrator that does it: Kubernetes (as a liveness probe), Docker Swarm, or a
helper such as autoheal.
A worker the engine has expired (its heartbeat gets a 404) exits with code 1 and
does not re-register on its own, so keep restart: unless-stopped (or on-failure)
as well: that restart is what brings it back.
Log-based monitoring
The worker logs structured events at key lifecycle points:
| Event | Level | Indicates |
|---|---|---|
NEOPS Worker starting... |
INFO | Worker initializing |
Found N function block(s) |
INFO | Discovery completed |
Registering worker with backend... |
INFO | Registration in progress |
Processing N job(s) |
INFO | Jobs received and processing |
Received SIGTERM. Shutting down... |
INFO | Graceful shutdown initiated |
Shutdown timeout of Ns elapsed with a job still running |
ERROR | The drain hit SHUTDOWN_TIMEOUT; the job’s outcome is unknown to the engine |
Result push for job … failed … retrying |
WARNING | The engine is unreachable; delivery is retried for up to RESULT_PUSH_TIMEOUT |
Worker registration failed |
ERROR | Cannot reach the workflow engine, it kept failing until STARTUP_RETRY_TIMEOUT, or it answered with a client error such as a 404 from a wrong URL_BLACKBOARD |
The engine refused this worker: … |
ERROR | The engine refused the worker’s client version (HTTP 426): the SDK does not match this engine. The line carries the engine’s reason, and the worker exits without retrying |
Worker expired! Backend rejected ping with 404. |
ERROR | Worker invalidated by backend |
Forward these logs to your monitoring stack (ELK, Grafana Loki, Datadog) for alerting.
Error Recovery
| Failure | Worker behavior | Engine behavior |
|---|---|---|
| Network partition | Misses heartbeats, reconnects on recovery; a finished job’s result is re-sent for up to RESULT_PUSH_TIMEOUT |
Marks worker offline after 6 min and fails its in-flight jobs as worker-offline (compensating reversible blocks); accepts a late result until then |
| Function block exception | Reports failure result to blackboard, without side_effects, with any rollback data already saved |
Assumes side effects; retries the step if the block is pure or idempotent. A step that fails for good makes the run FAILED_SAFE if it is pure, is rolled back if it is reversible, and otherwise makes the run FAILED_UNSAFE |
| Worker crash | Process exits | Fails the in-flight job as worker-offline after 6 min; never re-queues it |
| Workflow engine restart | Registration and result delivery are retried with backoff | Re-accepts worker registrations |
| Engine down at worker start | Retries registration for STARTUP_RETRY_TIMEOUT, then exits 1 |
— |
Security Considerations
- Credentials: Device credentials live in the CMS. Workers access them
through
WorkflowContext. Never store credentials in function block code or environment variables. - Network access: Workers need network access to both the workflow engine API and the managed devices. Use network segmentation to limit blast radius.
- TLS: Configure
URL_BLACKBOARDwithhttps://in production. The underlying HTTP client respects standard TLS settings. - Least privilege: Each worker only needs access to the devices its function blocks manage. Use separate workers for different network zones.

