brain/wiki/execution-runtime/workers.md
Separate Node processes that poll the app for jobs and execute flows/triggers. The worker is the sandbox: each forks the engine in-process via @activepieces/sandbox (createSandboxRuntime) — no separate sandbox pool. Destination model is one box per worker (concurrency 1), scaling out horizontally with small-capped replicas so an OOM kills one job, not a shared pool. A transitional mode still honors AP_WORKER_CONCURRENCY.
The deep Resolver/Runtime concurrency and bundle-caching model lives on the Execution Runtime page — see it for provision/acquire/release, flow-bundle caching, and code-step build stubs.
FETCH_WORKER_SETTINGS, get a WorkerSettingsResponse (incl. APP_VERSION), and the app registers an RPC server (WorkerToApiContract) per socket.poll(), not pushed. Handing out a job moves it to BullMQ active, so the app records it under the polling socket.id (jobAssignmentTracker, keyed queue+jobId). Worker executes → periodic extendLock → completeJob clears the assignment.connectionGeneration++ stops the loops; the app returns that socket's still-held jobs to the queue (releaseConnectionJobs(socket.id)) so they don't sit orphaned in active (the "Job stalled" storm); the worker aborts its in-flight runtime. Reconnect recreates the runtime fresh (kills any lingering job rather than colliding on the reused box).stop() (SIGTERM) drains in-flight jobs first (≤25s); the stalled-scan is the backstop for abrupt death.AP_CONTAINER_TYPE (APP / WORKER / WORKER_AND_APP) picks what starts under PM2. AP_REUSE_SANDBOX reuses the engine process between jobs.AP_WORKER_GROUP_ID + AP_PROJECT_WORKER) — one routing primitive; the flag, not a prefix, encodes scope. AP_PROJECT_WORKER=true (default) → project scope, polls project-<label>-jobs, routes only EXECUTE_FLOW/EXECUTE_WEBHOOK. false → platform scope, polls platform-<id>-jobs, routes all job types. Empty id = shared queue. Per-project routing is EE, gated behind platform_plan.workerGroupsEnabled.concurrency === 1 primes the sandbox to the full container RAM (cgroup-aware) via primeFullContainerMemory().versionsAreCompatible (fail-closed — undefined or UNKNOWN_VERSION '0.0.0' is treated incompatible). App withholds jobs from a mismatched worker (poll returns null); worker pauses polling 10s. Ordinary mismatch self-heals on convergence; a read failure does not (cached for process life) and pages on-call once at startup via assertReleaseReadable.process.cwd()/package.json (deploy-root), not a workspace file. Two failed reads are treated incompatible on purpose (not "same release").JobPayload (inline or ref fileId), forwarded unchanged; there is no worker→API payload-fetch RPC.GET /v1/health/system returns a release block (skew across connected workers); /v1/worker-machines/queue-metrics (all editions) feeds KEDA autoscaling.SANDBOX_CODE_ONLY + concurrency 1 = one immortal engine child allowed to grow to the whole box. These are two safe-looking settings that only misbehave together. canReuseSandbox() (sandbox-manager.ts) returns true for SANDBOX_CODE_ONLY and UNSANDBOXED only, so the engine child is reused forever and release() is a no-op — the process never resets between jobs. At concurrency === 1, primeFullContainerMemory() then overrides the operator's AP_SANDBOX_MEMORY_LIMIT with total container RAM, so that immortal child forks with --max-old-space-size = the entire box. V8 feels no pressure and never returns memory. Measured on 0.86.3, arm64, 16 GB box, AP_SANDBOX_MEMORY_LIMIT=512: the log reads fullContainerMemoryKb: 16332416 (the 512 is silently discarded) and the single sandbox RSS climbs 172 → 235 → 241 → 373 → 375 MB across 6.2k runs — monotonic, never released. In the process-based modes the sandbox is invalidated after every job, so the same workload shows no growth; that is why this only ever gets reported as a SANDBOX_CODE_ONLY "leak". At the default AP_WORKER_CONCURRENCY=5 priming does not fire, and you instead get five persistent children honoring the 512 MB cap (~1.9 GB steady, plateauing) — higher floor, but bounded. Note AP_FLOW_WORKER_CONCURRENCY does not control this; AP_WORKER_CONCURRENCY does, and it defaults to '5' (worker/src/lib/config/configs.ts).WorkerJobType must be added to USER_INTERACTION_JOB_TYPES in packages/server/api/src/app/workers/job-queue/job-queue.ts. jobBroker.completeJob only publishes the engine response back to the waiting webserver for job types in that set — miss it and the caller hangs to WATCHER_SAFETY_TIMEOUT_MS (5 min) with no error. submitAndWaitForResponse has only that backstop, so any best-effort caller must additionally cap itself (Promise.race with a short timeout); the losing engine job still runs to completion, so the cap buys back user latency, not fleet capacity.No handler = the worker runs the wrong edition. The single shared system-job-queue is consumed by whichever app instance runs startWorker(), and EE handlers only register in the CLOUD/ENTERPRISE branches of the edition switch. A worker on a different edition than the instance that scheduled the job throws No handler for job <name> every tick. Seen July 2026: ~14.6k failures, ~99% chat-stale-sweep, because the worker defaulted to community (AP_EDITION unset) inside a cloud deployment — CE jobs like file-cleanup-trigger ran fine on the same worker, every EE-scheduled job failed identically. Fix on the deployment (AP_EDITION=cloud), not by registering EE handlers in CE. The count looks huge because removeOnComplete: true hides successes and removeOnFail has an age cap but no count cap.JobSchedulerJson.id is almost always undefined — never filter schedulers on it. BullMQ only populates id on the legacy keyToData path (raw name:jobId:endDate:tz:pattern zset members); for both modern job schedulers and hashed legacy repeatables it is absent, and the identity you pass to removeJobScheduler is key. removeDeprecatedJobs (helper/system-jobs/system-job.ts) filtered on !isNil(f.id), so from 0.86.x it removed nothing — then the one-time pass found the scheduler's live delayed job and job.remove() threw Job repeat:<hash>:<millis> belongs to a job scheduler and cannot be removed directly (the lua refuses when rjk is still scored in the repeat zset), and the Promise.all aborted the rest of the cleanup. Order matters: remove the scheduler first, then the orphaned delayed job removes cleanly. Use allSettled for boot-time cleanup so one stuck entry can't block every other removal, and guard deprecated-name matching with !knownJobNames.includes(name) since the match is startsWith.kamal app exec on the worker image used to leak a permanent worker (fixed)docker-entrypoint.sh never honored "$@": whatever command it was handed, it built /tmp/ecosystem.config.js from AP_CONTAINER_TYPE and ended on pm2-runtime start. Since the Dockerfile uses exec-form ENTRYPOINT, a run-time command arrives as arguments, not a replacement — so kamal app exec <cmd> against activepieces-cloud never ran <cmd>. It inherited the role's AP_CONTAINER_TYPE=WORKER, booted a full permanent worker, and returned no output. One container per host, per invocation. Fixed by an if [ "$#" -gt 0 ]; then exec "$@"; fi placed before all setup, so an exec'd command skips JWT generation and the PM2 config write; safe because normal boot passes no arguments and the only CMD in the Dockerfile belongs to HEALTHCHECK.
One kamal app exec "ls /root/codes" on 2026-07-09 left 412 orphan containers (activepieces-shared05_<N>-exec-latest-<hash>, one per worker slot across 25 hosts) still running 18 days later with RestartCount: 0, holding ~86 GB RAM fleet-wide. And they were not harmless: they came up on 0.86.1, exactly the release the fleet was running that day, so the version gate did not block them — for ~11 days they were fully eligible workers executing production jobs as untracked containers no kamal deploy could drain or upgrade. The gate is what eventually stopped them once the fleet reached 0.86.2/0.86.3, not what prevented them. At least the second occurrence; a lone 0.85.2 worker from an earlier leak was already logging at 1,860/hr on 2026-07-08.
Two structural reasons it hid for 18 days: HEALTHCHECK exits 0 unconditionally for AP_CONTAINER_TYPE=WORKER (workers have no HTTP server), so orphans always read (healthy); and the version-mismatch warning fired ~1.9M times per 12h with nothing alerting on it. "A worker container is running a release other than the deployed one" is still unalerted — that is the gap worth closing.
A second leak shape exists that the exec-latest name filter misses. Found 2026-07-27 on app host 46.224.20.109: a container Docker had auto-named gracious_clarke, up since 2026-06-09, Cmd: ["bash"] — a plain interactive docker run, not kamal app exec, so no exec-latest in the name and nothing to filter on. The entrypoint booted pm2 anyway (same root cause as above, predating the fix), giving a full production APP node — AP_CONTAINER_TYPE=APP, live Postgres and Redis, AP_EDITION=cloud — running system jobs on a 6-week-old build with 2+ days of CPU burned. Being an APP, the worker version gate never applied to it at all. Note the registry claim below still holds but the local daemon does carry a latest tag: this container's .Config.Image reads :latest, resolving to image 313cc440c95d = 0.83.0.154094fb, three releases behind, 1.97 GB, present on no other app host because the live container blocks image prune. The detection that catches every shape regardless of name is count running containers per host against the expected number — the four healthy app hosts had 9, this one had 10; shared workers 29 (28 + proxy), dedicated 4. Compare docker ps --format '{{.Image}}' against the deployed tag for the same result.
Don't go hunting a stale :latest in the registry — activepieces-cloud has no latest tag published at all (4,000 versions scanned back to 2026-05-07); exec-latest is Kamal's naming for an exec container, not a registry reference. A leaked container is simply pinned forever to whatever release was deployed the day it leaked. Cleanup is name-scoped, since live containers are …-<version>.beta: docker rm -f $(docker ps -aq --filter name=exec-latest). Leave Exited (137) containers alone — they're the kamal rollback targets. Now that the entrypoint works, app exec is for fresh-container jobs (rollback and migration commands — the --entrypoint npm hack in docs/install/configure-operate/rollback.mdx can go, inspecting what shipped in an image, checking config resolution under a role's env); use --reuse when you need the running container's live state.
Whole-fleet throughput can collapse while every worker is healthy and idle, because dispatch, not execution, is the cap. createQueueDispatcher (queue-dispatcher.ts) runs one serial runLoop per queue name: every iteration awaits tryDequeue before handing a job to the next waiter, and for EXECUTE_POLLING / RENEW_WEBHOOK that includes a synchronous Postgres round-trip in zombiePollingInterceptor → triggerSourceRepo().findOneBy({ flowVersionId }). A queue's dispatch rate is therefore 1 / iteration_latency no matter how many workers poll — sustaining ~55 polling jobs/sec needs sub-18ms iterations including that query. No metric says "dispatcher saturated"; you infer it.
Don't "just" parallelize it. getNextJob moves a job wait → active at dequeue, so the app owns it before the worker starts and it only leaves active on completion or the slow stalled-scan. #11792 (2026-03-16) added the waiter queue precisely so a job is never pulled out of Redis unless a worker is already parked waiting; before that, active ran away past total worker concurrency, and that incident is pinned by test/unit/app/workers/job-queue/active-invariant.test.ts. The two invariants are in tension — serial dispatch protects active but caps throughput. #13962 (2026-06-28) added a second independent guard (jobAssignmentTracker + releaseConnectionJobs), so the serialization is arguably now redundant; #13998 (2026-07-01) made polls dequeue concurrently again and was reverted by #14316 (2026-07-20) with no recorded reason — note the concurrent version dropped the onOrphanedJob path, which likely motivated the revert. Any future fix has to satisfy both invariants at once.
Diagnostic signature (how it was found 2026-07-25/26), all from ClickHouse default.otel_logs: worker fleet constant (uniqExact(LogAttributes['host']) on event='system.snapshot'), per-job latency flat or improving (avg(executionMs) over job.execute) so workers aren't the bottleneck, and app-side dequeues falling on the same curve as executions. The decisive one is uniform stretch: for EXECUTE_POLLING, group by hour and compute count() / uniqExact(flowVersion.id). Flat distinctTriggers with decaying fires-per-trigger (13,050 triggers steady while each went 15.2/hr → 1.05/hr over 30h) means a shared dispatcher throttling all of them equally; triggers actually dying looks the opposite — distinctTriggers drops, fires-per-trigger holds. Rule out diurnal against the same hours a week earlier first.
Three latent hazards in the same file: await dequeue(...) has no timeout, so a hang on Redis leaves loopRunning true forever and every later poll() short-circuits, parks for WAITER_TIMEOUT_MS (50s) and returns null — that queue is dead for the life of the process and only a restart clears it. tryDequeue recurses once per skipped job (deferred-failure, invalid-schema, DISCARD, delay paths), so discarded zombie repeats burn dispatcher budget without producing executions (520k dequeues vs 270k executions) and a burst drives unbounded async recursion. And concurrency: 500 on the BullMQWorker is inert — autorun: false means BullMQ's internal run loop never starts, so it reads as 500-way parallel dispatch while being 1-way.
Incident 2026-07-26. workerJobs reached 229,682 prioritized / active:1 while all ~450 containers read Up (healthy). The workers were neither crashing nor disconnected: Socket.IO stayed live, they kept taking Flow published pushes and emitting system.snapshot heartbeats, they simply stopped calling poll — only 4–5 distinct worker ids appeared in [workerRpc#poll] Poll request received out of ~450.
buildMachineInfo() is awaited inside the poll loop, once per iteration, before apiClient.poll(). probeServerPing (added 2026-07-21 in PR #14181, purely to fill a serverPingMs field on the workers page) never consumed or cancelled its fetch response body, so its 5s AbortController did not release a request queued behind a busy undici client — the loop parked before its first poll, with no HTTP client handle left in process.getActiveResourcesInfo(). Three things kept that invisible: pm2 replaces the OOM-killed process every ~4 min inside the 1g container without Docker seeing anything (oom_kill 847 / pm2 ↺ 848 against RestartCount: 0, 38.7h wedged), the health server returned an unconditional 200 {"status":"ok"}, and the poll loop exited in complete silence.
Telling it apart, fast — one ratio and one grep:
Connected to API server via Socket.IO : Starting poll loops : Polling worker started. 1:1:1 with no job.execute after = wedged inside the loop. A connect with no following Starting poll loops is the if (polling) return latch in startPollingWorkers or a hung fetchAndStoreSettings instead.[workerRpc#poll] Withholding job = the version gate, not this. Then count distinct worker ids polling against fleet size — that single number separates "workers stopped asking" from "app stopped answering".prioritized huge + active ≈ 1 is not an app-side dispatcher wedge. The dispatcher is pull-only, so zero pollers means zero dequeues by design; a poll that does arrive is served in ~22ms.Recovery on a build that predates the fix (≤ 0.86.3.9c53aeaf): ~/restart-workers.sh on the DevOps box, one ssh per host, ~20 min fleet-wide. A fresh process is the only thing that re-arms the loop and it buys ~14 hours, not a fix — 13 of 14 sampled containers were wedged again the morning after the 2026-07-26 recovery. Two signals confirm the diagnosis while it rolls: wedged containers burn the full 30s SIGTERM timeout (a 2–3s stop means idle, not wedged), and restarted ones report (healthy) within 22s before polling anything, so watch workerJobs:active climbing instead. Keep 2–3 containers unrestarted for forensics — the script already skips four.
On a build that carries the fix a wedged loop restarts itself: probeServerPing races its own timeout and cancels the response body (wrapping buildMachineInfo() in a timeout alone was not enough — a hung probe still starved the loop through its 20s error-retry path), a watchdog exits the process after POLL_LIVENESS_TIMEOUT_MS (180s) with no loop iteration, the health endpoint 503s on the same condition, and the loop logs at warn when it exits. Liveness is tracked per poll loop, so with AP_WORKER_CONCURRENCY > 1 a healthy sibling cannot mask a wedged one. Still finding wedged containers on that build means the watchdog itself is not firing — new bug, preserve a container before restarting.
The OOM loop is the wedge's supply of attempts, and canary proves it. 2026-07-27, same buggy build on both tiers: the 7 canary workers (1.0 cpu, 1g, max-old-space-size=768) had 0 pm2 restarts and cgroup oom 0 after 46h uptime, and not one was wedged — all consuming 9–12 jobs per 10 min. The shared tier differs only in cpus: 0.5, and 35 minutes after a full fleet restart 135 of 448 shared slots (30%) were already wedged again, sampled containers showing 7–11 pm2 restarts in those 35 min (~1 every 3 min) with the textbook 1:1:1 connect/start/pollstart ratio. probeServerPing does not wedge a running worker; it wedges one on startup, and only the shared tier restarts often enough to keep rolling the dice. So raising shared worker cpu (or lowering max-old-space-size) is an independent mitigation that works without shipping the code fix — and it is what makes a recovery restart hold instead of decaying within the day.
Also outstanding: add packages/server/worker to CI's test filter — worker.test.ts does not run in CI today.
EXECUTE_POLLING and RENEW_WEBHOOK are veryLow = priority 5 (packages/core/execution/src/lib/workers/job-data.ts:51); webhooks and async flows are 3, sync flows and chat agents 2, user-interaction jobs 1. BullMQ pops the lowest score first, so when worker capacity drops, schedule and polling triggers are the first thing to go silent while everything else still looks fine. That makes "my */1 schedule flow isn't triggering" the earliest symptom of a partial fleet wedge, long before anyone calls it an outage. Seen 2026-07-27: 484 containers up but active:103 against a healthy ~408, the priority-5 lane holding 13,190 jobs with 5,318 of them 15–30 min overdue; one customer's poll job moved rank 8803 → 7319 in 8 minutes, i.e. ~20–75 min per poll on a one-minute cron.
Reading the lanes: BullMQ scores are priority * 2^32 + counter, so ZCOUNT bull:workerJobs:prioritized <p>*2^32 <p+1>*2^32-1 gives each lane's depth. A huge prioritized total is not by itself the signal — check which lane. Then take a repeat job's ZRANK twice a few minutes apart; that drift is the lane's true drain rate, far below the queue's overall completion rate because higher priorities keep cutting in. Lateness for a repeat job comes from the scheduled millis in its id (repeat:<flowVersionId>:<millis>), not the timestamp field, which is when the scheduler produced it — 24h early for a daily cron. Redis is DigitalOcean-managed and its cert does not verify from the DevOps box, so the working invocation is docker exec redis redis-cli --tls --insecure -h <AP_REDIS_HOST> -p 25061 --user default -a <pw>, with creds read off any activepieces-app_* container's env. Workers carry no Redis env at all — they reach the queue only through the app over Socket.IO.
Three things that look like bugs and aren't:
flowVersionId, but the new job joins the back of the same starved lane.repeat:<oldVersionId>:<millis> stays in prioritized and fires once against a soft-deleted trigger_source.RATE_LIMIT_PRIORITY is lowest = 6, so rate-limited EXECUTE_FLOW jobs re-enter behind the polling lane and never drain while polling is chronically backed up. The 2026-07-27 pile held jobs going back 19 days. Anything demoted to priority 6 on a queue with a standing priority-5 backlog is silently dropped.Entry point: worker, the exported lifecycle object in worker.ts; worker.start(...) is wired up in the worker process main.
packages/server/worker/src/lib/worker.ts — worker lifecycle, the pollAndExecute loop, version gate, in-flight drainpackages/server/worker/src/lib/config/ — worker env vars and the cached WorkerSettingsResponsepackages/server/worker/src/lib/runtime/ — bridges worker settings to sandbox settings, primes full container memorypackages/server/sandbox/src/lib/ — the @activepieces/sandbox library: Runtime / Resolver, caches, piece install, type contractspackages/server/api/src/app/workers/machine/ — Socket.IO listeners, machine service, worker-machine and queue-metrics routespackages/server/api/src/app/workers/rpc/ — app-side RPC handlers: poll, completeJob, extendLock, flow-bundle callspackages/server/api/src/app/workers/job-queue/ — job assignment tracker and queue routingpackages/core/execution/src/lib/workers/ — WorkerProps, MachineInformation, WorkerSettingsResponse, WorkerToApiContractpackages/server/utils/src/ap-version.ts — getCurrentRelease() and versionsAreCompatible()Paths verified 2026-07-17. An earlier version pointed at packages/core/shared/src/lib/automation/workers/; it moved to packages/core/execution/src/lib/workers/.