Clustering
By default, a single Worker instance holds all connected WebSocket clients in memory. This is sufficient for most setups. If you need high availability or want to spread load across multiple servers, you can run several Worker instances connected through a Redis Pub/Sub channel.
When Do You Need Clustering?
| Situation | Solution |
|---|---|
| Single server, moderate load | Single instance (default) |
| High availability / zero-downtime restarts | 2+ instances + Redis |
| Very large contest with many operators | 2+ instances + Redis + load balancer |
For a typical club station or even a large multi-op contest, a single Worker instance on a modest VPS handles hundreds of concurrent WebSocket connections without issue.
How Cluster Mode Works
When redis_url is configured, each Worker instance:
- Publishes broadcast events to a shared Redis Pub/Sub channel (
wavelog:events) - Subscribes to that channel and forwards incoming events to its local clients
- Stores topic registrations in Redis (with a 24-hour TTL) so all nodes share the same registry
- Announces itself in the Redis hash
wavelog:nodeswith a heartbeat every 5 seconds (Worker 0.3.0+). Any node can therefore report the whole cluster, and Wavelog only needs a singleworker_urlinstead of a list of all nodes. The node name is the hostname (the pod name in Kubernetes).
This means a publish arriving at any node is instantly forwarded to clients on all nodes — regardless of which instance they connected to.
graph TB
PHP[Wavelog PHP] -->|POST /internal/publish| W1
W1[Worker Instance 1] -->|Publish| R[(Redis)]
W2[Worker Instance 2] -->|Subscribe| R
W3[Worker Instance 3] -->|Subscribe| R
W1 --> C1[Client A]
W1 --> C2[Client B]
W2 --> C3[Client C]
W3 --> C4[Client D]
R -->|Fan out| W2
R -->|Fan out| W3
Each instance also delivers events to its own local clients directly, without waiting for the Redis round-trip.
Setup
1. Redis
You need a Redis instance reachable by all Worker instances. A minimal Redis setup with Docker:
docker run -d \
--name redis \
--restart unless-stopped \
-p 6379:6379 \
redis:7-alpine redis-server --save ""
Alternatively, use a managed Redis service (Upstash, Redis Cloud, etc.).
2. Worker Configuration
Add redis_url to each Worker's config.yaml:
Use a dedicated Redis database (e.g. /2) to avoid key collisions with other applications sharing the same Redis instance.
3. Load Balancer
Route incoming WebSocket connections across your Worker instances. The key requirement is sticky sessions (also called session affinity) — a browser's WebSocket upgrade and all subsequent frames must reach the same Worker instance. Standard WebSocket load balancing with any reverse proxy handles this automatically once the connection is established, but ensure your health check is pointed at /internal/status.
Example: Nginx upstream
upstream wavelog_worker {
least_conn;
server worker1.internal:9000;
server worker2.internal:9000;
server worker3.internal:9000;
}
server {
listen 443 ssl;
# ... TLS config ...
location /worker/ {
proxy_pass http://wavelog_worker/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
}
Docker Compose (multi-instance)
services:
redis:
image: redis:7-alpine
command: redis-server --save ""
restart: unless-stopped
wavelog-worker-1:
image: ghcr.io/wavelog/wavelog_worker:latest
hostname: worker-1
restart: unless-stopped
depends_on:
- redis
ports:
- "9000:9000"
volumes:
- ./worker/config.yaml:/app/config.yaml:ro
wavelog-worker-2:
image: ghcr.io/wavelog/wavelog_worker:latest
hostname: worker-2
restart: unless-stopped
depends_on:
- redis
ports:
- "9002:9000"
volumes:
- ./worker/config.yaml:/app/config.yaml:ro
With redis_url: "redis://redis:6379/2" in config.yaml, both instances share state through the Redis container. hostname: gives the nodes readable names on the Wavelog debug page; without it you see the container ID.
In Wavelog's worker.php one URL is enough, for example $config['worker_url'] = 'http://wavelog-worker-1:9001'; (or a load balancer in front of port 9001).
Kubernetes
Because the nodes discover each other through Redis, a plain Deployment with any number of replicas works. No StatefulSet, no headless service, no per-pod URLs.
apiVersion: apps/v1
kind: Deployment
metadata:
name: wavelog-worker
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
selector:
matchLabels: { app: wavelog-worker }
template:
metadata:
labels: { app: wavelog-worker }
spec:
containers:
- name: worker
image: ghcr.io/wavelog/wavelog_worker:latest
ports:
- { name: ws, containerPort: 9000 }
- { name: internal, containerPort: 9001 }
readinessProbe:
httpGet: { path: /readyz, port: internal }
volumeMounts:
- { name: config, mountPath: /app/config.yaml, subPath: config.yaml, readOnly: true }
volumes:
- name: config
secret: { secretName: wavelog-worker } # contains config.yaml with worker_secret
---
apiVersion: v1
kind: Service
metadata:
name: wavelog-worker
spec:
selector: { app: wavelog-worker }
ports:
- { name: ws, port: 9000, targetPort: ws }
- { name: internal, port: 9001, targetPort: internal }
The worker's config.yaml needs internal_bind: "0.0.0.0" and redis_url pointing at your Redis/Valkey service. In Wavelog set $config['worker_url'] = 'http://wavelog-worker:9001'; and route the browser WebSocket (/ws) to port 9000 of the same service through your ingress. Scaling the deployment up or down is reflected on the Wavelog debug page within a few seconds.
Verifying Cluster Mode
The /internal/status endpoint of any node reports the whole cluster:
{
"status": "ok",
"cluster_nodes": 3,
"nodes": [
{ "id": "3f9c2a1b7d4e6f80", "name": "worker-1", "version": "0.3.0", "alive": true, "uptime": "3h22m0s", "active_topics": 2, "connected_clients": 5, "connected_sockets": 6, "...": "..." },
{ "id": "8a1d0c4e2b6f9e13", "name": "worker-2", "version": "0.3.0", "alive": true, "uptime": "3h21m58s", "...": "..." },
{ "id": "c72e5b9a0d1f4e88", "name": "worker-3", "version": "0.3.0", "alive": false, "uptime": "1h02m11s", "...": "..." }
],
...
}
nodesis the cluster roster from Redis: one entry per Worker instance, sorted by name.aliveistruewhile the node's heartbeat is younger than 15 seconds.nullmeans Redis is currently unreachable.cluster_nodesis the number of instances currently subscribed to the Redis Pub/Sub channel (a live count without grace window).-1means single-instance mode (no Redis configured).
Node Lifecycle
| Event | Effect on nodes |
Wavelog debug page |
|---|---|---|
| Node starts | Entry appears within 5 s | node count goes up |
Clean stop (SIGTERM, docker stop, scale-down, rolling update) |
Entry removed immediately | count goes down, never "Degraded" |
| Crash (SIGKILL, OOM, host lost) | Entry stays with alive: false for 15 minutes, then is forgotten |
"2/3 Degraded" for up to 15 minutes, then "2/2" |
| Node returns under the same hostname | Dead entry replaced immediately | back to normal at once |
So "Degraded" on the debug page means a node disappeared without saying goodbye. If that was intentional (you killed it), it clears itself after 15 minutes.
Redis Unavailable
If Redis is configured but unreachable at startup or lost later, the Worker keeps running and serving its own WebSocket clients, retries the connection with backoff (1 s to 30 s) and resumes cluster mode on its own once Redis is back:
While disconnected, GET :9001/readyz returns 503 so a Kubernetes readiness probe or load balancer health check keeps traffic away from the node, events are not synchronised with the other instances, and nodes in /internal/status is null (Wavelog then falls back to polling the URLs it has configured). No restart is needed.
Topic Registry and Restarts
In single-instance mode, topic registrations are held in memory and lost on restart. Wavelog detects this via a 404 response from /internal/publish and re-registers the topic transparently.
In cluster mode, topic registrations are stored in Redis and survive individual instance restarts. Topics expire automatically after 24 hours and are refreshed whenever Wavelog re-registers them (e.g. on page reload or contest start).