Workers & Daemons Guide¶
Everything for console commands, long-running workers, and the daemon supervisor — one place, with worked examples for every case.
The short version (nothing you knew got harder). The three classes you already use are unchanged in spirit:
CommandBase— the base for every console command (adds a single-instance lock).ProcessQueue— a ready-made queue worker (extendsCommandBase).DaemonOrchestrator— supervises long-running daemons.What's new is that the daemon plumbing (locking, heartbeats, stop-signals, systemd notifications) was pulled out of those classes into four small standalone primitives. You almost never construct them yourself —
CommandBasecomposes them for you. They exist so the classes above share one implementation, and so a plain script that is not a command can reuse the same building blocks. If you only ever extendCommandBase/ProcessQueue/DaemonOrchestrator, you can skip straight to those sections.
The layers¶
Primitives (plain PHP · no base class · optional · compose freely)
┌───────────────┬────────────────┬─────────────┬──────────────────┐
│ WorkerLock │ WorkerReloader │ SignalStop │ SystemdNotifier │
│ lock+heartbeat│ code/settings │ graceful │ sd_notify │
│ +.stop+wedged │ reload │ SIGTERM/INT │ (Type=notify) │
└───────▲───────┴───────▲────────┴──────▲──────┴────────▲─────────┘
└───────────────┴── composed by ┴───────────────┘
│
┌─────────┴──────────┐
│ CommandBase │ base for all commands (lock-guarded)
└─────────┬──────────┘
│ extends
┌─────────┴──────────┐
│ ProcessQueue │ ready-made queue worker
└────────────────────┘
DaemonOrchestrator (extends CommandBase) ── spawns & supervises ──► worker processes
(a command, or a bespoke script)
Decision guide — "I want to…"¶
| Goal | Use |
|---|---|
| A one-shot command that must not run twice at once | CommandBase → beginJob()/endJob() |
| Continuously process the framework queue | ProcessQueue subclass |
| A bespoke long-running loop as a console command | CommandBase → installStopSignals() + while (!shouldStop()) + heartbeat() |
| A bespoke long-running loop as a standalone script (no Symfony Console) | the primitives directly (WorkerLock [+ SignalStop/SystemdNotifier/WorkerReloader]) |
| Supervise one or more of the above as background daemons | DaemonOrchestrator subclass |
| Report worker/daemon health to a dashboard | DaemonOrchestrator::status() + WorkerLock::readState() |
1. CommandBase — the base for commands¶
Extend it for any console command. Two usage modes:
1a. Simple lock-guarded command (one-shot)¶
The classic pattern — guarantees a single instance, cleans up on Ctrl+C:
use Pramnos\Console\CommandBase;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class RebuildSearchIndex extends CommandBase
{
protected function configure(): void
{
$this->setName('search:reindex')->setDescription('Rebuild the search index');
}
protected function getJobName(): string { return 'SEARCH_REINDEX'; }
protected function execute(InputInterface $input, OutputInterface $output): int
{
if (!$this->beginJob($output)) {
return self::FAILURE; // already running → prints a message
}
try {
// ... do the work ...
return self::SUCCESS;
} finally {
$this->endJob(); // release + remove the lock
}
}
}
beginJob() also installs a SIGINT handler that cleans up and exits — right for a
one-shot command.
1b. Long-running worker command (a loop)¶
For a daemon-style loop, use the cooperative stop instead, so a SIGTERM/deploy never cuts a job in half:
protected function execute(InputInterface $input, OutputInterface $output): int
{
if ($this->checkIfRunning()) {
$output->writeln('<error>Already running.</error>');
return self::FAILURE;
}
$this->startJob(); // acquire the lock
$this->installStopSignals(); // trap SIGTERM + SIGINT → cooperative flag
$this->systemd()->ready(); // no-op unless under systemd Type=notify
try {
while (!$this->shouldStop()) { // signal ∪ .stop sentinel
$did = $this->processOneBatch();
// Records state + pings the systemd watchdog; false ⇒ our lock was
// taken over by a replacement worker, so stop.
if (!$this->heartbeat(['jobs_processed' => $did])) {
break;
}
if ($did === 0) {
sleep(1); // idle back-off
}
}
} finally {
$this->endJob(); // release + remove the lock, notify STOPPING
}
return self::SUCCESS;
}
The two stop models¶
beginJob() (simple) |
installStopSignals() (worker) |
|
|---|---|---|
| Traps | SIGINT | SIGTERM and SIGINT |
| On signal | clean up + exit now | raise a flag; finish the job, then exit |
| Loop guard | — | while (!$this->shouldStop()) |
| Best for | one-shot commands | long-running loops |
Pick one per command; don't mix them.
Method reference¶
| Method | Description |
|---|---|
getJobName(): string |
Lock name (override; used for the lock file + .stop sentinel) |
getJobLockFilePath(): string |
Lock path (default ROOT/var/<jobName>) |
getLockStaleSeconds(): int |
Age past which a lock is treated as stale (default 2 h) |
beginJob(OutputInterface, bool $registerShutdown=true): bool |
Guard + acquire; false if already running |
endJob(): void |
Release + remove the lock; notify systemd STOPPING |
checkIfRunning(): bool |
Is another live instance holding the lock? |
startJob(): void |
Acquire the lock (no guard/handler; for the manual worker pattern) |
heartbeat(array $extra=[]): bool |
Refresh heartbeat + record $extra + watchdog; false if the lock was taken over |
installStopSignals(?callable $onStop=null): void |
Cooperative SIGTERM/SIGINT graceful stop |
shouldStop(): bool |
Loop guard: trapped signal or the .stop sentinel |
signalStop(): SignalStop / systemd(): SystemdNotifier / workerLock(): WorkerLock |
The composed primitives, if you need them directly |
1c. The schedule, and the two ways to run it¶
The framework has periodic work of its own — buffered writes to flush, queued
rows to write into compressed chunks, finished queue items to clear. It declares
that itself, in Pramnos\Scheduling\FrameworkSchedule, so an application does
not have to know it exists. Either of these runs it:
With cron — one line, and it keeps working when a later framework version adds a task:
Without cron — one long-running process, for containers, under systemd, or as an image's command:
php pramnos work # until told to stop
php pramnos work --once # one pass, then exit
php pramnos work --interval=30 # check every 30 seconds
php pramnos work --max-runtime=3600 # exit hourly for a supervisor to restart
work holds a single-instance lock and stops cooperatively, so a SIGTERM
during a task lets that task finish.
Three things about that lock, because each of them is the sort of decision that looks like an oversight:
--oncedoes not take it. It is the cron equivalent, and a crontab line that refused to run because aworkprocess holds the lock would be a cron line that silently does nothing, once a minute, for ever. The cost is the honest one: run a crontab line and aworkdaemon on the same installation and a task withoutwithoutOverlapping()can run twice. Pick one shape.- A lock taken over stops the worker.
heartbeat()answersfalseonce another process owns the lock — which is what a deploy looks like from inside, the replacement having started before this one was told to stop. Carrying on is how every job runs twice for the length of a rollout. --max-runtimeexits 0. A supervisor is told to restart on exit, and a planned recycle that reports a non-zero status becomes a crash in whatever reads the unit's history.
--interval=0 is clamped to one second rather than taken literally; the loop
sleeps in one-second steps so a SIGTERM is noticed within a second instead of at
the end of the interval.
With a daemon orchestrator — nothing to do. DaemonOrchestrator supervises
work alongside the application's own daemons, so a project that has an
orchestrator already runs the schedule. See
§3.
The queue worker does not need a controller to exist¶
queue:process asks the application for a controller called Queueitems — the administration
screen for queued jobs — because Application\Model will not be constructed without a
Controller to hold. An application that has no such screen used to get
«Cannot find controller: Queueitems» from a background worker, at start-up, for a class it was
never going to render anything with; under a supervisor that means respawn, fail, repeat, with
a queue that never drains. It falls back to a plain Controller now. An application that has
one still gets it.
This is not the queue worker¶
queue:process runs background jobs — things an application dispatches and
expects within seconds. work runs the schedule — things that happen on the
clock. They want opposite things: a queue worker polls constantly to keep
latency low, a scheduler sleeps a minute at a time. Run both:
One broken job does not stop the pass¶
The argument for one process rather than a crontab line each, and the thing that makes it safe: a task that raises is reported, counted, and stepped over. On an installation without cron this process is the only thing running the background, so a pass that abandoned itself on the first exception would stop the buffered writes, the queue and the cleanup along with the broken job — and keep stopping them every minute, for ever, on the strength of one bug in one task.
Three outcomes, three marks, and only one of them is a failure:
✓ |
the task ran | timed, and logged with its duration |
↷ |
run() answered false |
the no-overlap lock is held by the previous minute's copy — the mechanism working, not a failure |
✗ |
the task raised | counted, with the message, and written to the schedule channel |
The middle row matters more than it looks: counted as a failure, a job that legitimately takes
three minutes would report two failures for every success, and the count is what --once turns
into an exit code for a cron line.
And a failure is logged, not only printed. work runs under systemd or as a container command,
where nothing is attached to the output — a failure that exists only on a terminal nobody is
watching is a failure nobody knows about.
How a command task is run¶
Scheduler::command('spool:drain') shells out, and the console it shells out to is the
one this process is running:
The running entry point, not a fixed name — a scaffolded application's console is
<cliName>.php in the project root, not the framework's pramnos. Define PRAMNOS_BIN
to point somewhere else:
A non-zero exit fails the task. schedule:run prints ✗ Failed and returns non-zero;
work counts it and carries on with the rest of the pass. Both write it to schedule.log.
Which puts a burden on every scheduled command: exit non-zero only for something a person
should look at. spool:drain is the worked example, since it runs every minute:
| nothing buffered | exit 0, and silent |
| rows written | exit 0 |
| a row kept for the next run | exit 0 — the retry budget being spent is the spool working |
| a row parked as unwritable | exit 1 — data set aside, no further attempt, somebody must look |
The kept case used to exit non-zero, and the scheduler recorded a failure every minute until the budget ran out. One deployment read "3 errors in 200 seconds" as three tasks failing when it was one task failing three times — which is the ambiguity that costs an afternoon, and the reason the parked case is a usable signal now: it is the only one that fires.
The same argument makes the empty case silent rather than logging a line a minute. A log
nobody reads is where the one line that mattered goes unnoticed; -v says it for somebody who is
watching on purpose.
Corrected 2026-08-20. The default used to be the literal
php pramnos, and the exit status was discarded. In a scaffolded application everycommandtask therefore answeredCould not open input file: pramnoswhile reporting✓ Done— includingspool:drain, which meant a write spool that was never drained. Found on an installation with 478 rows invar/spool/and atokenactionstable that had never had a row in it.
Overlap protection across containers¶
Scheduler::command('slow:job')->hourly()->withoutOverlapping();
Scheduler::command('very:slow')->daily()->withoutOverlapping('', 7200); // wait up to 2h
The lock is a WorkerLock: it records the
host beside the pid, and trusts the pid only when the host matches. Where it does not —
an application container and a daemon container sharing one var/ — the holder is judged by
how recently it reported instead. The second argument is that threshold, for a task that
legitimately runs longer than the default two minutes.
Corrected 2026-08-20. The lock used to be a bare pid file checked with
posix_kill($pid, 0). A pid is a fact about the process table of whoever is asking, so in a two-container stack each side read the other's pid, found some local process holding that number, and concluded the task was still running — for ever, silently, because "still running" is a normal answer. The check-then-write was also a race two schedulers a millisecond apart both passed;acquire()is a single atomic create.
Replacing a framework task¶
// app/schedule.php
use Pramnos\Scheduling\FrameworkSchedule;
use Pramnos\Scheduling\Scheduler;
FrameworkSchedule::disable('spool:drain'); // or ::disableAll()
Scheduler::command('spool:drain')->everyFiveMinutes();
The application's file is loaded before the framework registers, which is
what makes disable() usable at all. An application that disables these takes
on running them itself.
1d. The write spool — rows written out of the request path¶
Some rows are worth keeping and worth nothing individually: an access log, an audit
trail, a hit counter. WriteSpool::append() puts one somewhere cheap and the scheduled
spool:drain writes what has accumulated, batched, out of the request path.
\Pramnos\Database\WriteSpool::append('#PREFIX#tokenactions', [
'tokenid' => 7, 'urlid' => 3, 'method' => 'POST', 'servertime' => time(),
]);
php pramnos spool:drain # write everything buffered
php pramnos spool:drain --status # how much is waiting, and where
Rows that can never be written¶
A spool is a promise to write a row later, and later can arrive after the row stopped
being writable. The common case is a foreign key: tokenactions references usertokens,
and a token cleaned up while its rows waited takes the key with it.
Such a row is retried five times and then parked — appended to
<table>.spool.failed with the error that stopped it and a timestamp, removed from the
spool, and never read back:
{"parked_at":"2026-08-20T04:11:07+03:00","attempts":5,
"error":"insert or update … violates foreign key constraint","row":{"tokenid":3907,…}}
spool:drain --status reports the count; WriteSpool::parked() returns it. Parked rows
are not pending — nothing will try them again — so read that file, decide, and delete
it.
Tuning:
php pramnos spool:drain --max-attempts=10 # more patient
php pramnos spool:drain --max-attempts=0 # never park; retry for ever
Added 2026-08-20. There was no limit: a row that could not be written was requeued unconditionally. One installation whose drain had never run accumulated a backlog whose tokens were cleaned up in the meantime; when the schedule started working, the same 209 rows failed every minute, for ever, printing a line each. Identical failures are now reported once with a count, too — two hundred lines a minute for one reason is how the reason stops being read.
2. ProcessQueue — the ready-made queue worker¶
Extend it and supply your queue model; you get the daemon loop, dashboard, heartbeat,
graceful stop (SIGTERM/SIGINT/.stop), DB-reconnect and git-deploy handling for free.
use Pramnos\Console\Commands\ProcessQueue;
use Pramnos\Queue\Worker;
class MyQueue extends ProcessQueue
{
protected function getJobName(): string { return 'QUEUE_PROCESSOR'; }
protected function getDashboardTitle(): string { return ' MY APP QUEUE '; }
protected function getControllerName(): string { return 'Queueitems'; }
protected function createWorker($controller, ?string $workerId): Worker
{
$w = new Worker($controller, $workerId);
$w->registerTaskHandler('send_email', SendEmailTask::class);
return $w;
}
}
Run it:
php console my:queue --daemon # loop forever
php console my:queue --daemon --runtime=55 # cron-friendly: run 55s then exit
php console my:queue --limit=100 # process up to 100 then exit
php console my:queue --force # clear a stale lock and start
Common options: --daemon, --runtime=N, --sleep=N, --batch=N, --limit=N,
--type=a,b, --worker-id=N, --force, --start-from=…, --reverse-order.
It uses the primitives internally — you wire nothing.
3. DaemonOrchestrator — the supervisor¶
Extend it to declare which long-running processes should exist; it spawns them, restarts crashed ones, restarts on a stale heartbeat, and requests a graceful restart of all daemons when a new git commit is deployed.
use Pramnos\Console\DaemonOrchestrator;
final class MyDaemons extends DaemonOrchestrator
{
protected function getJobName(): string { return 'daemon_orchestrator'; }
protected function getDashboardTitle(): string { return ' MY DAEMONS '; }
protected function getEntryPoint(): string { return ROOT . '/console'; }
protected function buildDesiredProcesses(): array
{
return [
[
'id' => 'queue_1',
'daemon' => 'queue',
'workerId' => 'worker-1',
'lockFile' => ROOT . '/var/QUEUE_PROCESSOR_worker-1',
'shellCommand' => escapeshellarg(PHP_BINARY) . ' '
. escapeshellarg(ROOT . '/console') . ' my:queue --daemon --worker-id=worker-1',
'requireLockFile' => true, // health = a fresh heartbeat in the lock file
'profile' => 'email + notification queue',
],
];
}
}
The schedule comes with it¶
The list above is the application's daemons. The orchestrator supervises one more
without being asked — work, the framework's schedule worker (§1c) — under the id
schedule, with the standard lock file at var/pramnos-work.lock:
[started] stats pid=118
[started] realtime pid=30
[started] schedule pid=141 ← not in buildDesiredProcesses()
The framework declares periodic work of its own and a schedule only happens when
something runs it. A stack with an orchestrator and no crontab used to run none of
it: spool:drain never fired, rows written through the WriteSpool stayed in a
file, and every report reading the drained table showed "no data" for ever — a
symptom several layers away from its cause. An application answers for its own
daemons; the framework's are the framework's business.
Two overrides:
// Already have a crontab line for schedule:run? Running both is safe (every
// framework task takes an overlap lock) but pointless.
protected function includeScheduler(): bool { return false; }
// Or supervise it differently — a shorter interval, a different lock file.
protected function schedulerProcess(): array
{
return [
'id' => 'schedule', 'daemon' => 'schedule', 'workerId' => 'schedule-1',
'lockFile' => ROOT . '/var/pramnos-work.lock',
'tokens' => ['work', '--interval=30'],
];
}
An application that already declares work in buildDesiredProcesses() keeps its
own entry — recognised by what it runs, not by the id it was given. An application
whose console does not register work at all gets nothing added, rather than a
supervised entry that could only ever fail to start.
requireLockFile => true— health is a fresh heartbeat (mtime) in the worker's lock file (aCommandBase/ProcessQueueworker keeps this automatically viaheartbeat()).requireLockFile => false— health is process liveness only, and the status line says(pid alive)rather than(lock active)because that is all that was checked. It also switches off the stale-heartbeat restart, which is the one restart a pid check cannot make: a wedged daemon satisfies "pid alive" indefinitely. Prefertrue— a JSON lock is read fine,readWorkerPidFromLockFile()delegates toWorkerLock::pidFromFile().- Stopping a daemon = the orchestrator drops a
<lockFile>.stopsentinel; the worker'sshouldStop()(or a standalone script'sWorkerLock::stopRequested()) sees it and exits after the current job. Constants:HEARTBEAT_STALE_SECONDS(300),GIT_CHECK_SECONDS(60).
A supervised worker watches the supervisor's lock path, and stops painting¶
Two things follow from being spawned by DaemonOrchestrator, and both are read from
PRAMNOS_JOB_LOCK_FILE, which it exports:
- The lock path is the supervisor's, through
resolvedJobLockFilePath(). A worker that rebuilds the path itself can disagree, and disagreement here is invisible: a sentinel read where nothing writes is indistinguishable from no sentinel.queue:processhad three places still computing their own answer, one of them the daemon loop's "is my lock still there" check — so under any orchestrator naming its ownlockFile, that worker announced «stop signal detected» and left on its first pass, for ever. - No dashboard.
isSupervised()is true, and the interactive dashboard is skipped: it clears the screen and repaints in place, which in a log file is thousands of escape sequences and no readable history. That log is the only account of what the worker did; the live numbers belong on/admin/Services, which reads the state file.
A stop request has a deadline¶
Every stop — a process leaving the desired set, a redeploy, the orchestrator being
disabled — records when it was asked for. A worker still running STOP_GRACE_SECONDS
(30) later is sent SIGTERM and reported as its own event:
[waiting] realtime pid=30 — gracefully stopping, will restart when done (24s before SIGTERM)
[stop-timeout] realtime pid=30 — ignored the stop sentinel for 31s, sent SIGTERM
A daemon that polls its sentinel exits long before the deadline and never sees it. One that
cannot is the case the deadline exists for — and [stop-timeout] is a fact about that
worker, worth finding in a log.
The sentinel applies whatever requireLockFile says: it is the orchestrator's
instruction to that process, not a claim about its lock.
Corrected 2026-08-20. Only the teardown path had a deadline. A redeploy called
requestStopAll()and re-exec'd the orchestrator immediately; the new image knew only the state file, which recorded nothing about the stop, so the grace period never started — and arequireLockFile => falsedaemon was reported[ok]throughout, because the sentinel was read as part of a lock check it had switched off. Reported from a project where one worker ran for 1h32m across three deploys with its.stopsentinel on disk the whole time, serving every WebSocket client from the old code.
Health snapshot for a dashboard¶
$status = (new MyDaemons())->status(); // does NOT run a reconcile cycle
// [ 'running' => bool, 'pid' => int, 'heartbeat_age_seconds' => int, 'daemons' => [...] ]
heartbeat_age_seconds is the age of the state file, which the reconcile loop rewrites every
cycle — so a fresh mtime means the supervisor is not merely alive but actively cycling. A
live pid with a stale heartbeat is the third state, and it looks identical to healthy if you
only read the pid: the process is there and stuck.
For code with no instance to call — a web request cannot construct the application's orchestrator subclass — the two paths are static:
DaemonOrchestrator::stateFilePath(); // ROOT/var/daemon_orchestrator_state.json
DaemonOrchestrator::orchestratorLockPath(); // ROOT/var/DAEMON_ORCHESTRATOR.lock
The services screen needs the supervisor to be running¶
/admin/Services lists what the orchestrator manages, and its Stop, Start and Restart
buttons do not spawn or kill anything: they write and remove a sentinel file, and the
orchestrator acts on it on its next cycle.
So with no orchestrator running:
- Stop still works — a daemon polls its own stop file.
- Start and Restart do nothing whatsoever. No error, no message: the operator clicks, the page reloads, the service stays down.
A pid is not enough to tell whether it is running. The supervisor's normal home is a
container of its own — that is what pramnos init writes for an application with background
work — so the pid in its lock file belongs to that namespace, and a web request reading it is
in another. There the number matches something unrelated, or nothing at all: a working
supervisor reported dead, or a dead one reported alive because pid 14 happens to be Apache.
So the reading is the pid or a heartbeat younger than 120 seconds. The state file is on the shared volume and is rewritten every reconcile cycle, so a recent mtime means actively cycling, and it cannot lie in the dangerous direction — a stopped supervisor stops touching it. A live pid with a stale heartbeat still counts as running, deliberately: that is the stuck-supervisor state, and the screen's warning for it is more useful than silence.
The screen therefore reports the supervisor's own state above the list — running with its pid
and last cycle, stale if it has not cycled for two minutes, and a warning naming the
consequence if it is not running at all. GET /admin/Services/status carries the same reading
as orchestrator, which is what a monitor should look at first: with the supervisor gone,
"0 running" is the expected number rather than an incident.
4. Standalone script worker (no CommandBase)¶
When a worker is a plain script (e.g. worker.php, not a Symfony Console command), reach
for the primitives directly — the exact same ones CommandBase composes:
use Pramnos\Console\WorkerLock;
use Pramnos\Console\SignalStop;
use Pramnos\Console\SystemdNotifier;
use Pramnos\Console\WorkerReloader;
$lock = new WorkerLock('worker', WorkerLock::defaultPath('worker'));
$systemd = new SystemdNotifier();
$reload = new WorkerReloader(ROOT, ['src', 'worker.php', 'composer.lock'],
fn () => MySettings::versionStamp());
if (!$lock->acquire($takenOverFrom)) {
exit("another worker holds the lock\n"); // still running / wedged
}
$reload->baseline();
$running = true;
$signals = new SignalStop([], function () use (&$running): void { $running = false; });
$signals->install();
$systemd->ready();
try {
while ($running) {
$processed = process_one_batch();
if (!$lock->heartbeat(['jobs_processed' => $processed])) {
break; // lock taken over → stop
}
$systemd->watchdog();
if ($lock->stopRequested()) break; // supervisor's <path>.stop sentinel
if ($reload->settingsChanged()) { rebuild_from_settings(); }
if ($reload->codeChanged()) {
// Under a supervisor, exit and let it restart on the new code; otherwise
// respawn yourself (see WorkerReloader::isSupervised()).
break;
}
if ($processed === 0) sleep(1);
}
} finally {
$systemd->stopping();
$lock->release();
}
This is why the primitives are standalone: a script gets the same locking, heartbeat,
graceful stop, systemd watchdog and reload guarantees as a CommandBase worker, without a
base class.
5. The four primitives (reference)¶
WorkerLock — single-instance lock + heartbeat¶
A JSON file whose atomic creation is the mutex (works where advisory flock() doesn't,
e.g. Docker bind mounts on macOS), doubling as the heartbeat.
| Method | Description |
|---|---|
acquire(?string &$takenOverFrom=null): bool |
Win the lock; take over a dead/wedged holder; refuse a live+progressing one |
heartbeat(array $extra=[]): bool |
Refresh (+ record state); false once taken over |
release(string $status='stopped'): void |
Mark stopped, keep the file for status reads |
stopRequested(): bool |
Is there a <path>.stop sentinel? |
isHeldByAnother(): bool / holderIsWedged(): bool |
Foreign-holder liveness / alive-but-stuck |
readState(): ?array / heartbeatAge(?array): ?int |
State inspection (for status) |
static pidFromFile(string): int / static defaultPath(string $name, ?string $dir=null): string |
Legacy-aware pid read / <dir>/<name>.lock |
SignalStop — cooperative graceful stop¶
$stop = new SignalStop(); // SIGTERM + SIGINT
$stop->install();
while (!$stop->requested()) { /* one job */ }
// or break a blocking loop: new SignalStop([], fn () => $server->stop())
SystemdNotifier — sd_notify¶
$sd = new SystemdNotifier(); // reads NOTIFY_SOCKET; no-op off Type=notify
$sd->ready(); $sd->watchdog(); $sd->stopping(); $sd->status('processing');
WorkerReloader — code / settings reload¶
$r = new WorkerReloader(ROOT, ['src', 'worker.php', 'composer.lock'], fn () => $stamp);
$r->baseline();
if ($r->settingsChanged()) { /* rebuild snapshot objects in place */ }
if ($r->codeChanged()) { /* exit for a restart on the new code */ }
WorkerReloader::isSupervised(); // is something going to restart me if I exit?
git-HEAD vs fingerprint. The orchestrator's git-HEAD detection (§3) and
WorkerReloader::codeChanged()both mean "restart on new code", but at different layers: the orchestrator (supervisor, git-deploy-granular, production) vs the worker itself (file mtime, git-less / dev--watch-files/ self-respawn). They are complementary — the worker's own reload is normally off when a git+orchestrator setup owns restarts.
6. Deployment recipes¶
systemd, watchdog (recommended for a command or script worker):
[Service]
WorkingDirectory=/srv/app
ExecStart=/usr/bin/php /srv/app/console my:queue --daemon # or /srv/app/worker.php run
Restart=always
RestartSec=5
Type=notify # the worker's ready()/watchdog() drive this
WatchdogSec=120 # a missed watchdog ping → systemd restarts it
systemd, simplest: drop Type=notify/WatchdogSec; Restart=always still restarts a
crash, and the single-instance lock prevents overlap.
cron (no supervisor): run --runtime=55 (ProcessQueue) or --max-runtime=55 (the RCB
script) polls for 55 s of each minute:
Under DaemonOrchestrator: run the orchestrator itself as the single systemd service; it
spawns and supervises the workers from buildDesiredProcesses().
Creating the orchestrator service — Ubuntu / Debian¶
This is the whole thing, on a stock Ubuntu or Debian box. /admin/Services links here when it
finds no supervisor running.
1. Check the command exists. An application declares its own orchestrator — a
DaemonOrchestrator subclass, conventionally named daemons:start:
If nothing comes back, there is no supervisor to run yet: write the subclass first
(§3). A scaffolded application gets one in
src/ConsoleCommands/Daemons.php.
2. The unit file — /etc/systemd/system/app-daemons.service:
[Unit]
Description=Application daemon orchestrator
# Ordering only. `After` does not wait for the database to be *ready*, which is the
# failure this file is written around — see the restart policy below.
After=network-online.target postgresql.service mysql.service
Wants=network-online.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/srv/app
ExecStart=/usr/bin/php /srv/app/console daemons:start
# `always`, not `on-failure`. This process's worst failure is a *clean* exit: a machine
# that comes back before the database accepts connections boots the framework into its
# maintenance page and returns 0. `on-failure` looks at that and correctly does nothing,
# leaving the supervisor gone with every other service up and healthy beside it.
Restart=always
RestartSec=5
# It supervises children; let systemd clean up the whole tree.
KillMode=mixed
TimeoutStopSec=60
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
3. Enable it:
sudo systemctl daemon-reload
sudo systemctl enable --now app-daemons
systemctl status app-daemons
sudo journalctl -u app-daemons -f # what it is doing, live
4. Confirm from the application, not from the pid — /admin/Services reads the
orchestrator's own state file, so it can tell a live process from one that has stopped
cycling. DaemonOrchestrator::status() is the same reading for a monitor.
Two things that go wrong on a fresh box:
var/is not writable. The locks, heartbeats and state file live inROOT/var, and underUser=www-dataa directory owned by the deploying user is a supervisor that starts and immediately cannot record anything:sudo chown -R www-data:www-data /srv/app/var.- A crontab is already running the schedule. The orchestrator supervises the schedule
worker itself, so both together run every scheduled job twice. Remove the
schedule:runcrontab line, or overrideincludeScheduler()tofalse.
In Docker, the same process is a service beside the app, sharing its image and volume so it runs the code the site is running:
daemons:
build: { context: . }
restart: unless-stopped # for the reason given above
command: php /var/www/html/console.php daemons:start
volumes:
- .:/var/www/html
depends_on:
- db
pramnos init writes exactly that for an application whose features have background work —
the queue, messaging, broadcasting, or the periodic jobs auth and authserver schedule.
See also¶
- Console Guide — command scaffolding, dashboards, terminal helpers.
- Queue Guide — the delayed-queue capability (
DelayedQueue). - Realtime Guide —
broadcast:serve(aCommandBaseworker).
broadcast:serve is supervised for you¶
When broadcasting.transport is websocket, the orchestrator adds the WebSocket
daemon to what it supervises, the same way it adds the schedule worker. An
application does not have to declare it.
That is not convenience. broadcast:serve is the process that turns a published
event into a frame in a browser; without it every subscription is a perfectly
healthy socket that never receives anything — the publish succeeded, the channel
exists, the client connected, and nothing anywhere says what is missing.
An application that declares its own entry keeps it, recognised by the
broadcast:serve token or by the command line, so passing --channels, a
certificate or a different port works as before. To take it over entirely: