k0smos Queue Workers With systemd
These examples run one long-lived queue:work process for one
tenant/logical-queue/slot tuple. The tuple is encoded in both the systemd
instance and its environment filename:
k0smos-queue-worker@<tenant>-<queue>-<slot>.service
/etc/k0smos/queue-worker-<tenant>-<queue>-<slot>.env
For the shipped topology, one tenant normally needs two processes:
| Instance | Logical queue | Slot |
|---|---|---|
k0smos-queue-worker@k0smos.example.com-default-1.service |
default |
1 |
k0smos-queue-worker@k0smos.example.com-notifications-1.service |
notifications |
1 |
Run php bin/console queue:topology --host=k0smos.example.com before creating
units. It reads tenant configuration and DB-backed notification settings and
reports the exact effective queues. A transport change between database and
Redis does not add a process.
Transport-specific provisioning is in Redis Transport, and the tuning a PrestaShop catalog import needs is in PrestaShop Imports Through Psapi. The subsystem itself — worker-count rule, producers, failure semantics — is canonical in the queue operations guide.
Prerequisites
- Deploy the application to
/opt/k0smos, or adjustWorkingDirectoryand the paths in the unit. - Verify
/usr/bin/phpis the intended production CLI binary and includespcntlon Linux. - Install Composer dependencies before starting workers.
- Run tenant migrations first. DBAL and Redis failover require
queue_jobsandqueue_worker_heartbeats. - Run every worker as the same Unix user as the PHP-FPM/FrankenPHP pool
that serves the tenant. The shipped unit uses
www-data:www-databecause that is the reference pool; when the pool runs as another user, changeUser=,Group=andUMask=in the unit to match it instead of sharing files between two users. See Worker User Must Match The Web Process. - Give that user read access to the release and write access to the tenant
runtime paths (
var/,log/,tmp/, uploads/storage, and SQLite files when SQLite is used). Keep environment files root-owned, group-readable by the worker user, and mode0640or stricter. - Ensure Redis is reachable only when
queue.driver=redis; database is the shipped transport and does not require Redis. See Redis Transport for the tenant keys, unit ordering, and instance requirements. - Ensure the configured Python runtime exists when a worker consumes advanced AI work.
Redis Transport
Redis replaces the storage behind the same queue contracts. It does not add a
logical queue, rename a unit, or change the process count: the tenant still runs
one instance per effective logical queue. Run queue:topology after switching
and expect an unchanged unit list with a redis primary and a database
fallback transport.
Enable The Transport
Set module_config.queue.driver to redis in
config/tenants/k0smos.example.com.json and fill the redis block:
"queue": {
"name": "default",
"driver": "redis",
"redis": {
"host": "${K0SMOS_REDIS_HOST:-127.0.0.1}",
"port": "${K0SMOS_REDIS_PORT:-6379}",
"db": "${K0SMOS_QUEUE_REDIS_DB:-0}",
"password": "${K0SMOS_REDIS_PASSWORD:-}",
"prefix": "k0smos:queue",
"timeout": 2.0
},
"max_attempts": 3
}
| Key | Default | Meaning |
|---|---|---|
driver |
database |
redis selects Redis primary with DBAL failover |
redis.host |
127.0.0.1 |
Must be reachable from the workers and from the web process |
redis.port |
6379 |
1-65535 |
redis.db |
0 |
Numeric database index |
redis.password |
empty | AUTH password; empty means no authentication |
redis.prefix |
k0smos:queue |
Base key namespace; the tenant id is appended |
redis.timeout |
2.0 |
Connect timeout in seconds; must be greater than zero |
The transport uses the redis PHP extension, which is a hard Composer
requirement. Confirm the production CLI binary loads it before enabling the
driver:
/usr/bin/php -m | grep -x redis
Both Sides Must Resolve The Same Transport
Tenant JSON placeholders read getenv(), then $_ENV, then $_SERVER, and the
resolved configuration is cached per environment fingerprint. A worker and a web
process that see different values for K0SMOS_QUEUE_DRIVER,
K0SMOS_REDIS_HOST, K0SMOS_QUEUE_REDIS_DB, or K0SMOS_QUEUE_PREFIX resolve
different transports without raising an error: the producer writes to one store
while the worker consumes the other, the producer finds no heartbeat, and every
dispatch silently degrades to its synchronous fallback.
Export the same variables in both places — the worker environment file and the PHP-FPM/FrankenPHP service environment — or write the values literally in tenant JSON and keep only the password in the environment.
Order The Units After Redis
Do not edit the shipped template unit. Add a drop-in when Redis runs on the same host:
sudo install -d -m 0755 /etc/systemd/system/k0smos-queue-worker@.service.d
sudo tee /etc/systemd/system/k0smos-queue-worker@.service.d/redis.conf >/dev/null <<'EOF'
[Unit]
After=redis-server.service
Wants=redis-server.service
EOF
sudo systemctl daemon-reload
Wants= is the right default because the DBAL failover keeps the worker useful
during a Redis outage. Use Requires= only when a worker must never run without
Redis. When Redis is on another host, keep the shipped
After=network-online.target and rely on failover plus Restart=always instead
of a host-local dependency.
Key Namespace
The effective prefix is the configured prefix plus the tenant id from the
tenant JSON, so tenant id 7 with the default prefix produces k0smos:queue:7.
Per logical queue:
| Key | Type | Content |
|---|---|---|
<prefix>:<queue>:pending |
list | Job ids waiting to be reserved |
<prefix>:<queue>:processing |
list | Job ids currently reserved |
<prefix>:<queue>:processing:timestamps |
sorted set | Reservation time per job id, read by stale reclaim |
<prefix>:<queue>:delayed |
sorted set | Job ids scheduled for a later time |
<prefix>:<queue>:jobs |
hash | Live job payloads |
<prefix>:<queue>:failed |
list | Dead-lettered job ids |
<prefix>:<queue>:failed:jobs |
hash | Dead-lettered payloads with the last error |
<prefix>:workers:<queue> |
sorted set | Worker id scored by last heartbeat timestamp |
Two tenants sharing one Redis database are separated only by that tenant id
segment. Give every tenant a distinct id, or a distinct redis.db or
prefix, before pointing a second tenant at the same server.
Read-only inspection while a worker runs:
redis-cli -n 0 llen k0smos:queue:7:default:pending
redis-cli -n 0 llen k0smos:queue:7:default:processing
redis-cli -n 0 zrange k0smos:queue:7:workers:default 0 -1 WITHSCORES
redis-cli -n 0 hlen k0smos:queue:7:default:failed:jobs
Job payloads carry application data. Treat redis-cli output as production data
and redact it before it reaches a ticket.
Instance Hygiene
- Use a database or an instance dedicated to queues. If the same Redis also
serves caches with an eviction policy such as
allkeys-lru, queue keys are evictable and jobs disappear without an error.maxmemory-policy noevictionis the correct setting for a queue store. - Jobs in Redis are exactly as durable as the Redis instance. Enable RDB or AOF persistence, or accept that a Redis restart loses everything that was never written to the DBAL fallback.
- Bind Redis to the private interface and set
requirepass. The queue transport authenticates but does not encrypt; keep the hop inside a trusted network.
Failover Behavior
In redis mode the transport is wrapped in a failover manager: dispatch,
reserve, reclaim, and heartbeat operations fall back to DBAL when the Redis
connection or the operation fails, and the failure is logged. Consequences:
- Queue migrations are mandatory.
queue_jobsandqueue_worker_heartbeatsare the live fallback store, not unused schema. - An outage does not migrate jobs that already exist only in Redis; they become visible again when Redis returns.
- After an outage, work can exist in both stores at once. Keep the workers
running until both drain, and read the primary/fallback pair reported by
queue:topology.
Install The Unit And Environment Files
sudo cp doc/example/systemd/k0smos-queue-worker@.service /etc/systemd/system/
sudo install -d -m 0750 /etc/k0smos
sudo install -m 0640 -o root -g www-data \
doc/example/systemd/k0smos-queue-worker.env.example \
/etc/k0smos/queue-worker-k0smos.example.com-default-1.env
sudo install -m 0640 -o root -g www-data \
doc/example/systemd/k0smos-queue-worker.env.example \
/etc/k0smos/queue-worker-k0smos.example.com-notifications-1.env
Set the tuple explicitly in each file:
# queue-worker-k0smos.example.com-default-1.env
TENANT_ENV=k0smos.example.com
QUEUE_NAME=default
QUEUE_WORKER_SLOT=1
# queue-worker-k0smos.example.com-notifications-1.env
TENANT_ENV=k0smos.example.com
QUEUE_NAME=notifications
QUEUE_WORKER_SLOT=1
Leave QUEUE_WORKER_ID unset unless an external naming policy requires it.
k0smos then generates k0smos.example.com.default.1 and
k0smos.example.com.notifications.1, so logs and heartbeat rows remain
unambiguous.
Worker User Must Match The Web Process
A tenant's runtime files are created by whichever process touches them first:
the PHP-FPM/FrankenPHP pool on a web request, or the shell that runs
bin/console for migrations and imports. On SQLite tenants that includes the
database file itself. If the queue worker runs as a different Unix user, even
one in the same group, the following happens:
- The pool or shell creates
var/db/<id>.sqlitewith its own umask, usually022, so the file is0644: the group can read it but not write it. - The worker writes on its first loop (heartbeat row, job reservation). SQLite
answers
attempt to write a readonly database, the process exits with status1in under a second. - systemd restarts it until
StartLimitBurstis exhausted, then leaves both unitsfailed. The per-tenant target still reportsactive, because a target is active once it has been reached and does not track the units it lists inWants=. queue:topology --host=<tenant>reportsmissing_workerfor every logical queue, and every producer silently falls back to synchronous execution.
The same command started by hand from the owning user's shell works, which is the tell-tale sign: the configuration is right and only the identity differs.
Preferred remedy: one user for the whole tenant runtime
Set the unit to the user and group of the pool that serves the tenant. Use a drop-in so the shipped unit file stays untouched:
# /etc/systemd/system/k0smos-queue-worker@.service.d/user.conf
[Service]
User=deploy
Group=www-data
UMask=0002
Place the drop-in under
k0smos-queue-worker@k0smos.example.com-default-1.service.d/ instead when
only one tenant differs. Then reload and clear the exhausted restart counter:
sudo systemctl daemon-reload
sudo systemctl reset-failed 'k0smos-queue-worker@k0smos.example.com-*'
sudo systemctl restart k0smos-queue-workers-k0smos.example.com.target
php bin/console queue:topology --host=k0smos.example.com
Confirm the identities match:
ps -o user= -C php-fpm8.5 | sort -u
systemctl show 'k0smos-queue-worker@k0smos.example.com-default-1.service' -p User
ls -l var/db/*.sqlite
On a production host the pool should be an unprivileged service user such as
www-data; align the worker to it. On a shared development host where the
pool already runs as the developer's account, aligning the worker to that
account adds no exposure that the pool does not already have.
Fallback: two users with a group-writable regime
Keeping distinct users for the pool and the worker is supported only when every writer honours a group-writable regime, and it stays fragile because one missing piece reproduces the failure on the next new file:
- setgid on every tenant runtime directory (
chmod g+s var var/db var/cache var/media), so new files inherit the shared group; - umask
002in the pool configuration and in every interactive shell that runsbin/console; UMask=0002in the unit: the shipped0027makes files created by the worker unwritable by the other user, which is the same problem in reverse;- a one-off
chmod g+won existing SQLite files and their-wal/-shmcompanions; - the tenant JSON under
config/tenants/, the.env*files and the release checkout readable by the shared group.
Prefer the single-user remedy unless a policy explicitly requires separate identities.
Enable The Normal Two-process Topology
sudo systemctl daemon-reload
sudo systemctl enable --now \
k0smos-queue-worker@k0smos.example.com-default-1.service \
k0smos-queue-worker@k0smos.example.com-notifications-1.service
Add one systemd instance for every distinct effective logical queue that has
an asynchronous producer. Multiple message/job types on the same queue do not
add units. A custom ai, imports, or notification queue does add a unit when
it is distinct from the existing names.
Scale One Logical Queue
To run two default consumers, copy the environment file, set slot 2, and
enable a second tuple. Do not reuse slot 1.
sudo cp \
/etc/k0smos/queue-worker-k0smos.example.com-default-1.env \
/etc/k0smos/queue-worker-k0smos.example.com-default-2.env
sudo sed -i 's/QUEUE_WORKER_SLOT=1/QUEUE_WORKER_SLOT=2/' \
/etc/k0smos/queue-worker-k0smos.example.com-default-2.env
sudo systemctl enable --now \
k0smos-queue-worker@k0smos.example.com-default-2.service
The expected generated worker id is k0smos.example.com.default.2.
Optional Per-tenant Target
Copy k0smos-queue-workers-k0smos.example.com.target to /etc/systemd/system/,
rename it for the real tenant when necessary, edit its Wants= list to match
queue:topology, then enable it:
sudo systemctl daemon-reload
sudo systemctl enable --now \
k0smos-queue-workers-k0smos.example.com.target
A target is preferable when deployment automation should start, stop, and inspect the tenant's complete worker set as one unit. Enabling instances directly is simpler for a single queue or independently managed capacity.
PrestaShop Imports Through Psapi
PrestaShop imports are the heaviest workload the shipped topology carries and the usual reason to revisit these units. They have no lane of their own.
| Question | Answer |
|---|---|
| Logical queue | default, fixed in the launcher; configuration cannot move it |
| Unit that runs an import | k0smos-queue-worker@k0smos.example.com-default-1.service |
| Queued payload | Job id, profile (core or extended), dry-run flag — never credentials |
| Attempts | queue.max_attempts (shipped 3), then dead letter |
| No active worker | The back office runs the whole import inside the HTTP request |
A dedicated imports queue is a code change, not a setting: queue.name does
not rename a producer that dispatches to default explicitly.
The Back Office Decides Async Or Sync At Launch Time
POST /api/admin/psapi/import dispatches to the queue only when the default
lane has a heartbeat newer than 180 seconds. Otherwise it logs
queue.sync_fallback, raises a back-office warning, and runs the import inside
the web request, where a PHP or proxy timeout can kill it mid-run.
Check both before launching a large import:
php bin/console queue:topology --host=k0smos.example.com
systemctl is-active k0smos-queue-worker@k0smos.example.com-default-1.service
/admin/psapi/import-status reports the mode the run actually used. Treat a
sync production run as a worker outage, not as a variation.
A Busy Worker Is An Invisible Worker
The worker writes its heartbeat around reservation and completion, not during a
handler. A single default worker spending twenty minutes inside a PrestaShop
import therefore has a heartbeat older than 180 seconds for most of that time,
and every other default producer degrades while the import runs: contact mail,
payment webhooks, and Deadline messages execute synchronously in their caller,
and advanced AI — which rejects instead of falling back — answers 503.
notifications is a separate lane and is unaffected.
For a tenant that imports during business hours, run a second default slot so
one worker always stays free:
sudo cp \
/etc/k0smos/queue-worker-k0smos.example.com-default-1.env \
/etc/k0smos/queue-worker-k0smos.example.com-default-2.env
sudo sed -i 's/QUEUE_WORKER_SLOT=1/QUEUE_WORKER_SLOT=2/' \
/etc/k0smos/queue-worker-k0smos.example.com-default-2.env
sudo systemctl enable --now \
k0smos-queue-worker@k0smos.example.com-default-2.service
A second slot also introduces reclaim, so read the next section before enabling it.
Tune The Three Timers That Interrupt An Import
| Setting | Where | Shipped value | Effect on a long import |
|---|---|---|---|
TimeoutStopSec |
unit | 180s |
systemctl stop/restart escalates to SIGKILL mid-import once it elapses |
reclaim_after / QUEUE_RECLAIM_AFTER_SECONDS |
tenant JSON / environment file | 600 in the shipped tenant JSON, 60 when unset everywhere |
Another worker on the same queue returns the in-flight job to pending |
memory_limit |
PHP CLI ini | php.ini default | An exhausted worker dies mid-run and leaves the run row running |
Reclaim is based on reservation age, not on worker liveness, and a worker busy
inside a handler never reclaims anything itself. With one slot the in-flight
import is safe. With two or more slots and reclaim_after below the import
duration, the sibling worker re-delivers the job: the import does not run twice
— the job store refuses to claim a run updated within the last 900 seconds — but
the delivery consumes an attempt and removes the queue job while the original is
still working, so that run loses its retry.
Set every timer above the longest expected run:
sudo install -d -m 0755 /etc/systemd/system/k0smos-queue-worker@.service.d
sudo tee /etc/systemd/system/k0smos-queue-worker@.service.d/imports.conf >/dev/null <<'EOF'
[Service]
# Let a running import finish before systemd escalates to SIGKILL.
TimeoutStopSec=3600s
# Catalog imports hold more per-process memory than mail or webhook jobs.
ExecStart=
ExecStart=/usr/bin/php -d memory_limit=512M /opt/k0smos/bin/console queue:work
EOF
sudo systemctl daemon-reload
sudo systemctl restart k0smos-queue-worker@k0smos.example.com-default-1.service
The empty ExecStart= is required: without it systemd appends a second command
instead of replacing the shipped one.
Raise the reclaim window in the same tuple's environment file:
QUEUE_RECLAIM_AFTER_SECONDS=3600
A drop-in under k0smos-queue-worker@.service.d/ applies to every instance of
the template. Use the per-instance directory
k0smos-queue-worker@k0smos.example.com-default-1.service.d/ when only the
import lane needs the longer timeout.
Also review QUEUE_MAX_JOBS and QUEUE_MAX_IDLE_SECONDS. They recycle a worker
after N jobs or N idle seconds and are a deliberate memory-hygiene measure; they
never interrupt a running handler, because both are evaluated between jobs.
Watch A Running Import
sudo journalctl -u k0smos-queue-worker@k0smos.example.com-default-1.service -f
sudo journalctl -u k0smos-queue-worker@k0smos.example.com-default-1.service \
--since "1 hour ago" | grep -E 'psapi\.import_failed|queue\.sync_fallback'
TENANT_ENV=k0smos.example.com php bin/console k0smos:psapi:diagnostics
The journal shows dispatch and failure events. The durable psapi_import_runs
and psapi_import_run_entities rows — surfaced by /admin/psapi/import-status
— remain authoritative for status, counters, and checkpoints.
Very Large First Imports
Run the first full catalog import as a foreground CLI job rather than a
back-office launch, so it never occupies the shared default lane:
TENANT_ENV=k0smos.example.com php bin/console k0smos:psapi:import --dry-run
TENANT_ENV=k0smos.example.com php bin/console k0smos:psapi:import --profile=core
Use --entity=products --since=2026-09-01 for a bounded rerun. Leave the
workers running while the CLI import proceeds: they keep serving mail, webhooks,
and notifications.
Deploy, Restart, And Monitor
Recommended deployment order:
- Put the web application into the deployment's maintenance/drain mode when required.
- Stop or gracefully restart workers before replacing code that changes message classes or handlers.
- Deploy code and Composer dependencies.
- Run tenant migrations.
- Run
queue:topology --format=jsonand reconcile environment files/units. - Restart the target or the affected instances, then restore web traffic.
sudo systemctl daemon-reload
sudo systemctl restart \
k0smos-queue-worker@k0smos.example.com-default-1.service \
k0smos-queue-worker@k0smos.example.com-notifications-1.service
sudo systemctl status \
k0smos-queue-worker@k0smos.example.com-default-1.service
sudo journalctl \
-u k0smos-queue-worker@k0smos.example.com-default-1.service -f
systemctl stop sends SIGTERM. The worker stops reserving new jobs and exits
after its current handler returns. TimeoutStopSec=180s is the final bound;
work exceeding it may be killed and later reclaimed according to the queue's
stale-job settings. Keep handlers idempotent because retry and crash recovery
provide at-least-once delivery.
Before restarting a default worker, check that no import is running. A
systemctl restart during a PrestaShop import interrupts it at
TimeoutStopSec, and the run row stays running until the job store's
900-second stale window allows a new claim.
Troubleshooting
| Symptom | Likely cause | Check |
|---|---|---|
| Every back-office import runs synchronously | No default worker, or its heartbeat is older than 180 seconds |
systemctl is-active, then queue:topology --host=… |
| Imports run synchronously although the worker is active | Worker and web process resolve different transports or Redis instances | Compare K0SMOS_QUEUE_* and K0SMOS_REDIS_* in the environment file and in the PHP-FPM/FrankenPHP service |
Jobs accumulate in pending and nothing consumes them |
No worker for that logical queue name, or a custom queue.name with no producer |
queue:topology, then redis-cli llen <prefix>:<queue>:pending |
| Worker starts and exits immediately | Unresolvable TENANT_ENV, missing migrations, an unreachable Redis with a broken DBAL fallback, or a tenant file the worker user cannot write |
journalctl -u … -n 50 |
Exit status 1 in under a second, attempt to write a readonly database in the journal, SQLite file mode 0644 owned by another user |
The worker runs as a different Unix user than the pool/shell that created the tenant files | ls -l var/db/*.sqlite versus systemctl show … -p User; apply Worker User Must Match The Web Process |
Per-tenant target is active but queue:topology reports missing_worker |
A target is active once reached and does not track its Wants= units, which are failed after StartLimitBurst |
systemctl list-units 'k0smos-queue-worker@*'; after fixing the cause run systemctl reset-failed, otherwise systemd never retries |
| An import restarts from the beginning | Reclaim window shorter than the import; a sibling worker returned the job to pending |
Raise QUEUE_RECLAIM_AFTER_SECONDS above the longest run |
A killed import stays running |
The worker was SIGKILLed at TimeoutStopSec |
Raise TimeoutStopSec; the run is claimable again after 900 idle seconds |
| Jobs disappear without a failure entry | Redis evicted queue keys, or Redis restarted without persistence | redis-cli config get maxmemory-policy and the persistence settings |
Unable to connect to Redis for queues. in the journal |
Wrong host/port/password, or Redis not started yet | Add the After=redis-server.service drop-in; verify redis.password |
Dead-letter inspection and retry are a separate operational feature;
queue:topology is read-only by design.