Status: implemented (quick-260707-dh1) β code + compose only; a homelab redeploy lands it in prod.
Scope: the file-server (nox) SAQ agent worker. k8s burst clusters are unaffected (burst pods are one-shot phaze.job_runner, not the persistent SAQ worker).
The nox agent used to run a single SAQ worker with one shared concurrency pool
(concurrency = worker_max_jobs, default 8) serving every file-touching task. Two failures:
- I/O offload starved.
s3_upload(httpx multipart PUT) andpush_file(rsync-over-SSH) competed for the same slots as CPU-bound essentia analysis; a local analysis backlog left offload jobs stuck (4 files stuck inpushing, 2026-07-07). - Head-of-line blocking across analysis types. A deep
process_filebacklog made a newly-enqueuedfingerprint_file/extract_file_metadatawait behind it.
Splitting into per-type lanes buys fairness / no head-of-line blocking β not unlimited parallelism. nox has 8 physical cores; CPU-bound lanes must sum to β cores.
Each lane is its own SAQ queue phaze-agent-<agent_id>-<lane> consumed by one worker that
registers ONLY that lane's functions. The taskβlane map lives in ONE place β
LANE_TASKS in src/phaze/services/enqueue_router.py (the single source of truth; AGENT_TASKS
is its derived union). Both the producer (lane_for_task / resolve_queue_for_task) and the
consumer (the lane worker settings) derive from it.
| Lane | Tasks | Bound by | Concurrency env | Default |
|---|---|---|---|---|
analyze |
process_file |
Host CPU (in-process essentia) | PHAZE_LANE_ANALYZE_CONCURRENCY |
4 |
fingerprint |
fingerprint_file |
Host CPU (panako/audfprint) | PHAZE_LANE_FINGERPRINT_CONCURRENCY |
2 |
meta |
extract_file_metadata, scan_directory, scan_live_set, execute_approved_batch |
Light / fast | PHAZE_LANE_META_CONCURRENCY |
2 |
io |
s3_upload, push_file |
Network (off CPU budget) | PHAZE_LANE_IO_CONCURRENCY |
4 |
analyze(4) + fingerprint(2) = 6 CPU-bound slots on 8 cores, leaving headroom for the fast
meta lane, sidecar overhead, and the OS. The io lane is network-bound and runs off the
CPU budget. All concurrencies are env-overridable.
WORKER_MAX_JOBS is a ceiling in lane mode (quick-260707-g84). In lane mode the per-lane
concurrency knob (PHAZE_LANE_<LANE>_CONCURRENCY) governs the worker's concurrency, and
WORKER_MAX_JOBS acts only as an upper bound: concurrency = min(lane knob, worker_max_jobs).
So an explicit, lower WORKER_MAX_JOBS is authoritative and clamps every lane, but setting
WORKER_MAX_JOBS alone does not raise a lane above its knob. On the file-server defaults
(lane β€ 4, worker_max_jobs 8) the ceiling never bites and behavior is unchanged. The effective
concurrency, the lane, and whether the ceiling clamped it are logged once at worker startup.
essentia/TensorFlow are pinned single-threaded on the CPU lanes (analyze, fingerprint) so one
slot β one core and the budget stays honest: OMP_NUM_THREADS=1, TF_NUM_INTRAOP_THREADS=1,
TF_NUM_INTEROP_THREADS=1 (set in docker-compose.agent.yml). This addresses the load-18-on-8-cores
oversubscription observed under the old single pool.
The liveness heartbeat (Phase 46 asyncio background task) is agent-level, not lane-level. It runs
in exactly one lane worker β worker-analyze, via PHAZE_AGENT_HEARTBEAT=true (false on the other
three lanes) β so an agent reports one authoritative last_seen, never N duplicate heartbeats.
Caveat (A1, by design): the heartbeat reads ctx["worker"].queue, which in the analyze-lane worker
is the analyze lane's depth only. The heartbeat's queue_depth field is therefore analyze-lane-only,
NOT the whole agent. This is intentional and acceptable β the heartbeat needs only liveness, and cross-lane
reads would add coupling for a cosmetic field. The authoritative all-lane in-flight figure is the
dashboard's get_queue_activity (src/phaze/services/pipeline.py), which sums queued+active across all
four lane queues plus the legacy base queue per agent.
The compute agent is media-less and analysis-only; its ONLY task is process_file. Because producers
target lane-suffixed queue names uniformly, the compute agent consumes the single analyze lane
(docker-compose.cloud-agent.yml sets PHAZE_AGENT_LANE=analyze). It is NOT a 4-service split β the
fingerprint / meta / io lanes would be permanently empty on a compute host, and the I/O-starvation /
head-of-line problems the lane split solves are file-server-only. Single lane β single heartbeat (its
PHAZE_AGENT_HEARTBEAT is left unset β default true). k8s burst pods are untouched.
Memory-safety cap (quick-260707-g84). The OCI Ampere A1 compute host has only 12 GB RAM and a
single process_file job peaks ~8 GB, so the analyze lane is pinned to 1 concurrent job via
PHAZE_LANE_ANALYZE_CONCURRENCY=1 in docker-compose.cloud-agent.yml. This is the knob that
actually governs a lane worker; setting only WORKER_MAX_JOBS=1 is inert in lane mode (it is a
ceiling β concurrency = min(lane knob, worker_max_jobs) β so it can never lift the analyze lane's
default 4). Without this pin the compute agent silently ran 4 concurrent ~8 GB jobs and OOM-killed.
New enqueues route to lane queues immediately on deploy. In-flight jobs on the legacy un-suffixed
phaze-agent-nox queue must drain. The chosen mechanism is a transitional all-mode consumer β NOT a
re-enqueue.
Why not re-enqueue: re-driving an already-active multi-hour process_file onto a lane queue would
duplicate a running job (deterministic-key dedup guards queued enqueues, not an active job on a
different queue name). Finishing in place has no duplicate-active hazard, and any legitimate retry stays
idempotent via the deterministic key (s3_upload:<file_id>, push_file:<file_id>, process_file:<file_id>).
Steps (homelab):
- Deploy the new compose. Bring up the four lane workers (+ the compute
analyzelane on the cloud host):Producers now enqueue ONLY onto the lane queues, sodocker compose -f docker-compose.agent.yml up -d worker-analyze worker-fingerprint worker-meta worker-io watcher audfprint panako
phaze-agent-noxonly drains (never grows). - Start the transitional drain consumer (all-mode:
PHAZE_AGENT_LANEunset β all 8 functions on the legacy base queue;PHAZE_AGENT_HEARTBEAT=false):docker compose -f docker-compose.agent.yml --profile drain up -d worker-drain
- Watch the legacy queue drain. When
phaze-agent-noxreports 0 queued + 0 active (visible in the/saqdashboard β the base queue is mounted for exactly this window), remove the drain service:docker compose -f docker-compose.agent.yml --profile drain rm -sf worker-drain
Once worker-drain is removed, the migration is complete and all work flows through the four lane queues.
src/phaze/services/enqueue_router.pyβLANE_TASKS,LANES,lane_for_task,AGENT_TASKS(derived union).src/phaze/services/agent_task_router.pyβqueue_for(agent_id, lane)(lane required),all_lane_queues,legacy_base_queue.src/phaze/tasks/agent_worker.pyβ lane-parametrized settings driven byPHAZE_AGENT_LANE; single-lane heartbeat gate.src/phaze/config.pyβPHAZE_LANE_*_CONCURRENCY+PHAZE_AGENT_HEARTBEAT.docker-compose.agent.yml/docker-compose.cloud-agent.ymlβ the lane services + drain profile.