Multi-Node Deployment Requirements
May 5, 2026 · View on GitHub
Overview
This document outlines the requirements and recommendations for deploying the workflow v2 engine across multiple server nodes. Multi-node deployments enable horizontal scaling, high availability, and geographic distribution.
The v2 engine separates correctness substrate from acceleration layer. The shared workflow database is the correctness substrate; every multi-node guarantee in this document depends on it. Shared cache and the wake-notification layer are acceleration: they shorten discovery latency without affecting which tasks are eligible or who claims them. A deployment whose acceleration backend is degraded continues to make correct progress, bounded by the durable poll and repair cadence. See docs/architecture/scheduler-correctness.md for the full contract, including the reversible migration path that lets a deployment move between cache-coordinated wake notification and a stronger acceleration primitive (Redis pub/sub, PostgreSQL LISTEN/NOTIFY, NATS) without a cutover.
The engine-side contract for high availability and failover behavior inside a single region — managed-database failover, managed-Redis failover, API-node loss, worker loss, scheduler-runner restart, and the load-balancer / readiness rules that govern traffic shift during each event — is frozen in docs/deployment/ha-failover.md. The cross-region active/passive recovery contract lives separately in docs/deployment/multi-region.md.
Prerequisites
Shared Database
All nodes must connect to the same database instance:
- ✅ MySQL 8.0+
- ✅ PostgreSQL 13+
- ✅ MariaDB 10.5+
Connection Configuration:
All nodes share identical database credentials:
DB_CONNECTION=mysql
DB_HOST=db.internal.example.com
DB_PORT=3306
DB_DATABASE=workflows
DB_USERNAME=workflow_user
DB_PASSWORD=<secure-password>
Connection Pooling:
For high-throughput deployments, use connection pooling:
- ProxySQL (MySQL)
- PgBouncer (PostgreSQL)
- RDS Proxy (AWS)
Shared Cache Backend (Acceleration Layer)
A multi-node deployment runs correctly without a shared cache backend — discovery latency simply rises to the durable long-poll and task-repair cadence. Configuring a shared cache backend recovers the sub-second discovery latency that wake signals provide and is strongly recommended for production.
Acceleration backends supported for multi-node:
- ✅ Redis 6.0+ (recommended)
- ✅ Database cache (MySQL/PostgreSQL)
- ✅ Memcached 1.6+
- ❌ File cache — wake signals do not propagate across nodes
- ❌ Array cache — process-local; signals are lost on restart
The file and array cache stores cannot carry the multi-node acceleration layer because they cannot propagate wake signals between nodes. Pollers fall back to long-poll timeout when the acceleration layer is absent or unable to propagate; the durable substrate continues to govern correctness.
Redis Configuration:
All nodes share identical Redis credentials:
CACHE_DRIVER=redis
REDIS_HOST=redis.internal.example.com
REDIS_PASSWORD=<secure-password>
REDIS_PORT=6379
REDIS_DB=1
Database Cache Configuration:
Uses shared database for cache storage:
CACHE_DRIVER=database
# DB_* credentials same as above
Why a Shared Cache Backend Is Recommended:
When Node A creates a task, Node B's pollers can observe the wake signal and re-probe immediately rather than waiting for the long-poll timeout. File cache is per-node, so wake signals never reach other nodes — discovery still occurs on the next poll, just with higher latency. The system never silently loses work in this state; the backend_capabilities health check surfaces a warning so operators can detect and repair the acceleration layer.
See Long-Poll Coordination for the wake-signal protocol details and Scheduler Correctness for the bounded discovery latency contract that holds even with the acceleration layer absent.
Boot-time validation:
Set DW_V2_MULTI_NODE=true (env) or workflows.v2.long_poll.multi_node=true (config) to enable boot-time validation that the configured cache backend can carry the acceleration layer. Tune behaviour with DW_V2_VALIDATE_CACHE_BACKEND (default true) and DW_V2_CACHE_VALIDATION_MODE (fail, warn, or silent; default warn). The cache admission is warning-only by contract: silent suppresses the diagnostic, and warn and fail both log a warning without blocking boot. Validation failures surface through the backend_capabilities health check; they do not affect correctness.
Node Configuration
Environment Variables
All nodes should have identical configuration for workflow behavior:
# Namespace (consistent across nodes)
DW_V2_NAMESPACE=production
# Compatibility (deploy same build to all nodes)
DW_V2_CURRENT_COMPATIBILITY=build-20260415-1a2b3c4
DW_V2_SUPPORTED_COMPATIBILITIES=build-20260415-1a2b3c4
# Task dispatch
DW_V2_TASK_DISPATCH_MODE=queue
# Limits (consistent across nodes)
DW_V2_LIMIT_PENDING_ACTIVITIES=2000
DW_V2_LIMIT_PENDING_CHILDREN=1000
# ... etc
The legacy WORKFLOW_V2_* names are honored as fallbacks during the
deprecation window, but new deployments should use the DW_V2_* primary
names.
Node-Specific Variables
Each node can have unique values for:
# Worker identity (unique per node)
APP_NAME=workflow-node-1
# Local storage
FILESYSTEM_DISK=local
# Logging
LOG_CHANNEL=stack
LOG_LEVEL=info
Deployment Process
Zero-Downtime Deployment
-
Pre-deployment validation:
# Verify database connectivity php artisan migrate:status # Verify cache connectivity php artisan tinker >>> cache()->put('deploy-test', time()); >>> cache()->get('deploy-test'); -
Deploy to first node (canary):
git pull origin main composer install --no-dev --optimize-autoloader php artisan migrate --force php artisan config:cache php artisan route:cache php artisan view:cache -
Verify canary health:
curl https://node-1.example.com/api/health -
Deploy to remaining nodes (rolling):
- Deploy one node at a time
- Wait for health check to pass
- Move to next node
-
Post-deployment verification:
# Verify all nodes responding for node in node-{1..5}; do curl https://$node.example.com/api/health done
Database Migrations
Critical: Run migrations on ONE node only before deploying to others.
# On deployment leader node
php artisan migrate --force
Other nodes will see migrated schema on boot. Do NOT run migrations on multiple nodes simultaneously.
Load Balancing
HTTP Load Balancer
Distribute control plane API requests across nodes:
upstream workflow_api {
least_conn;
server node-1.example.com:443;
server node-2.example.com:443;
server node-3.example.com:443;
}
server {
listen 443 ssl;
server_name api.workflows.example.com;
location / {
proxy_pass https://workflow_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Health Check Endpoint:
GET /api/health
Returns 200 if node healthy.
Worker Distribution
Workers connect directly to database and cache. No load balancer needed.
Horizontal Scaling:
- Add more nodes → more polling capacity
- Each node polls independently
- Tasks claimed atomically (database fencing)
- No coordination overhead beyond wake signals
Monitoring
Key Metrics Per Node
Task Throughput:
- Workflow tasks claimed/sec
- Activity tasks claimed/sec
- Task completion rate
Poll Efficiency:
- Wake signal hit rate (% polls triggered by wake signal vs timeout)
- Timing hint hit rate (% polls using
available_athint) - Average poll latency
Cache Health:
- Cache hit rate
- Cache latency (p50, p99)
- Cache error rate
Database Health:
- Query latency (p50, p99)
- Connection pool utilization
- Deadlock rate
Aggregate Metrics
Cluster Capacity:
- Total tasks/sec across all nodes
- Total active workers
- Queue depth (ready tasks not yet claimed)
Wake Signal Propagation:
- Time from task creation to worker poll (p50, p99)
- Cross-node wake latency
Troubleshooting
Symptom: Tasks Stuck in Ready State
Check: Are all nodes polling?
-- Count ready tasks by queue
SELECT queue, COUNT(*)
FROM workflow_tasks
WHERE status = 'ready'
GROUP BY queue;
Fix: Ensure workers running on all nodes.
Symptom: Workers on Other Nodes Pick Up Tasks Only After Long-Poll Timeout
This is the wake-acceleration layer reporting degradation. Tasks still flow — pollers fall back to the configured long-poll timeout (default 30 seconds) and the durable task-repair loop continues to redeliver work — but the sub-second latency that wake signals provide is lost. Correctness is unaffected.
Check: Cache backend coordination
# Node 1: Create wake signal
php artisan tinker
>>> app('Workflow\V2\Support\CacheLongPollWakeStore')->signal('test-channel');
# Node 2: Check signal received
>>> $store = app('Workflow\V2\Support\CacheLongPollWakeStore');
>>> $before = $store->snapshot(['test-channel']);
>>> # (Node 1 signals again)
>>> $store->changed($before); // Should return true
Fix: Verify the shared cache configuration. Check network connectivity between nodes and cache backend. Inspect the backend_capabilities and long_poll_wake_acceleration health checks for explicit wake-layer status (acceleration-layer issues escalate to warning, never error).
If workers receive no tasks at all (not just delayed tasks), the symptom is durable-substrate failure, not acceleration degradation; investigate workflow_tasks repair, claim fencing, and worker compatibility instead.
Symptom: Duplicate Task Execution
This should not happen due to atomic claim fencing. If it does:
-
Check database isolation level:
SELECT @@transaction_isolation; -- Should be READ-COMMITTED or higher -
Check for clock skew between nodes:
date -u # Run on all nodes, compare -
File bug report with reproduction steps.
Best Practices
- Start with 2-3 nodes for most workloads
- Scale horizontally by adding nodes (not vertically)
- Monitor wake signal latency to detect cache backend issues
- Use connection pooling for high-throughput workloads
- Keep active-region nodes in the same datacenter/region to minimize
latency between API nodes, the workflow database, and Redis. For
regional failover, see the self-serve active/passive contract in
multi-region.md. - Use blue-green deployment for zero-downtime migrations
- Test multi-node locally using Docker Compose before production
Example Docker Compose Setup
version: '3.8'
services:
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: workflows
MYSQL_ROOT_PASSWORD: secret
volumes:
- db_data:/var/lib/mysql
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
node-1:
build: .
environment:
APP_NAME: workflow-node-1
CACHE_DRIVER: redis
REDIS_HOST: redis
DB_HOST: db
DB_DATABASE: workflows
DB_USERNAME: root
DB_PASSWORD: secret
depends_on:
- db
- redis
node-2:
build: .
environment:
APP_NAME: workflow-node-2
CACHE_DRIVER: redis
REDIS_HOST: redis
DB_HOST: db
DB_DATABASE: workflows
DB_USERNAME: root
DB_PASSWORD: secret
depends_on:
- db
- redis
volumes:
db_data:
redis_data:
Run:
docker-compose up -d
docker-compose exec node-1 php artisan migrate
docker-compose logs -f
Security Considerations
- Network isolation: Database and cache should not be public
- TLS/SSL: Use encrypted connections for database and Redis
- Authentication: Secure database and cache with strong passwords
- Firewall: Only workflow nodes should reach database/cache ports
- Secrets management: Use environment-specific secret stores (Vault, AWS Secrets Manager)
Performance Tuning
Database
- Index optimization: Ensure indexes on
workflow_tasks(status, queue, available_at) - Connection pooling: Use PgBouncer or ProxySQL
- Query optimization: Monitor slow query log
Cache
- Redis persistence: Consider RDB snapshots + AOF for durability
- Memory allocation: Plan for 10MB per 10,000 active channels
- Eviction policy: Use
volatile-lruorallkeys-lru
Application
- Polling concurrency: Increase workers per node for throughput
- Task dispatch mode: Use
queuemode for background processing - Lease durations: Tune based on task execution time (default: 10s)