Web Development

Graceful Shutdown for Long-Running PHP Workers - Finish the Current Job Before Exit

Graceful Shutdown for Long-Running PHP Workers - Finish the Current Job Before Exit

A queue worker is halfway through sending an invoice, generating a thumbnail, or updating a search index when a deployment asks it to stop. Exiting immediately may abandon the job after some effects have already happened. Ignoring the stop request may delay the deployment until the supervisor loses patience. What would a controlled middle path look like for a small PHP CLI worker?

The useful answer is narrower than “never lose a job.” A process can cooperate with a normal termination request, but it cannot make crashes, power loss, or forced termination disappear. Graceful shutdown is therefore a protocol: receive a request, stop accepting new work, reach a defined job boundary, and exit before a finite deadline. Queue recovery and idempotency still have to cover the cases where that protocol cannot finish.

Define the boundary before handling the signal

Consider a worker that repeatedly reserves one job, performs its effects, and acknowledges completion. The safest place to stop is usually between jobs, not in the middle of one. A termination signal should change what the loop does next; it should not turn the signal handler itself into a second execution path that closes database connections, edits queue state, and writes several log records.

For this article, a graceful stop means:

  • do not reserve another job after a stop has been requested;
  • allow the currently running job to reach its normal completion or failure path;
  • acknowledge work only according to the queue's established rules;
  • exit within the supervisor's stop deadline.

That definition already exposes an important limit. If one job can block forever on a network call, “finish the current job” can also mean “never stop.” Graceful shutdown needs bounded work: connection and request timeouts, a maximum job duration where practical, and a supervisor deadline that reflects reality rather than hope.

Let the handler request a stop

PHP's Process Control documentation describes PCNTL as Unix-style process control and warns against using it in a web-server environment. The extension is also unavailable on Windows. This pattern is for a long-running PHP CLI process on a Unix-like system, not for a normal PHP-FPM request.

The core mechanism can remain small:

<?php

declare(strict_types=1);

if (PHP_SAPI !== 'cli' || !extension_loaded('pcntl')) {
    fwrite(STDERR, "This worker requires PHP CLI with PCNTL.\n");
    exit(1);
}

$stopRequested = false;

pcntl_async_signals(true);

$requestStop = static function () use (&$stopRequested): void {
    $stopRequested = true;
};

pcntl_signal(SIGTERM, $requestStop);
pcntl_signal(SIGINT, $requestStop);

while (!$stopRequested) {
    $job = reserveNextJob();

    if ($job === null) {
        sleep(1);
        continue;
    }

    if ($stopRequested) {
        releaseJob($job);
        break;
    }

    try {
        processJob($job);
        acknowledgeJob($job);
    } catch (Throwable $error) {
        recordJobFailure($job, $error);
    }
}

fwrite(STDOUT, "Worker stopped cleanly.\n");

reserveNextJob(), processJob(), and the queue functions are deliberately placeholders. Their correct implementation depends on whether the queue is MariaDB, Redis, RabbitMQ, or something else. The example's claim is only about control flow: the handler sets a flag, the active iteration keeps its ordinary success or failure path, and the loop checks the flag before reserving more work.

The second check after reserveNextJob() closes another practical gap. A signal can arrive while the reservation call is waiting. If the call then returns a job, that job has not started, so the example releases it and exits. A real queue must make releaseJob() safe, or use a lease that makes an abandoned reservation eligible again. There is still no magic instruction that makes signal delivery and queue state one atomic event; the queue's recovery model remains essential.

The official pcntl_async_signals() documentation says that passing true enables asynchronous signal handling. The pcntl_signal() documentation identifies asynchronous dispatch as the recommended dispatch method. It also notes that blocking functions such as sleep() may be interrupted. More generally, Linux documents that some blocked calls are restarted after a handler while others return early with EINTR, depending on the interface and signal-handler flags. A worker using sockets, subprocesses, or a client library needs tests around those actual calls; the boolean flag does not make every dependency promptly interruptible.

Understand what systemd does during stop

A minimal service can run the worker as its main process:

[Unit]
Description=Example PHP queue worker
After=network.target

[Service]
Type=exec
User=app-worker
WorkingDirectory=/srv/example-app
ExecStart=/usr/bin/php /srv/example-app/bin/worker.php
Restart=on-failure
RestartSec=5s
TimeoutStopSec=45s

[Install]
WantedBy=multi-user.target

The paths, account, restart policy, and 45-second deadline are examples, not universal defaults. The account must exist and have exactly the queue, application, and filesystem access the worker needs. The deadline should be longer than the intended time to finish or safely fail one bounded job, while still placing a real upper limit on deployment and shutdown.

The official systemd.kill manual says that the initial stop signal defaults to SIGTERM. With the normal stop procedure, systemd waits for the service to exit. The TimeoutStopSec= documentation explains that a service still running after the deadline is forcibly terminated with SIGKILL by default.

This creates a concrete sequence:

  1. systemctl stop or a restart causes systemd to send SIGTERM.
  2. The PHP handler changes $stopRequested to true.
  3. The active job follows its normal completion or failure path.
  4. The loop sees the flag before reserving another job and exits.
  5. If the process misses the deadline, systemd normally escalates to SIGKILL.

Linux's signal(7) manual states that SIGKILL cannot be caught, blocked, or ignored. No shutdown handler can clean up after that signal. Disabling systemd's final kill would not solve the underlying problem; it can instead leave a service that refuses to stop. The better question is why the worker could not reach its boundary before the deadline.

Keep queue correctness separate from process lifetime

Finishing a PHP function is not, by itself, proof that a job was processed exactly once. Imagine a job that charges an external API and then acknowledges its queue record. The process could disappear after the charge succeeds but before the acknowledgment commits. On recovery, the queue may present the job again. Reversing the order is no safer: acknowledging first can lose the work if the process dies before the external effect.

The appropriate controls belong to the job and queue design:

  • Reservation or lease: claimed work becomes available again when a worker disappears or its lease expires.
  • Acknowledgment after the defined result: the queue marks completion only at the chosen boundary.
  • Idempotency: retrying the same logical job does not duplicate an irreversible effect, often by using a stable operation key or a uniqueness rule.
  • Atomic local state: related database changes commit together where one database transaction can actually cover them.
  • Attempt records and observability: operators can distinguish pending, active, completed, failed, and expired work.

Different brokers provide different primitives, so the article cannot prescribe one acknowledgment command honestly. The general lesson is that a signal coordinates process shutdown; it is not a transaction protocol. Graceful handling reduces avoidable interruption during ordinary maintenance, while recovery semantics handle interruption that still occurs.

Test the boundary rather than trusting the configuration

A safe first test uses disposable work, visible markers, and no production queue. Make one simulated job take several seconds, start the worker, send SIGTERM during that job, and inspect whether the current job finishes without the next one starting:

php graceful-worker-demo.php &
worker_pid=$!
sleep 1
kill -TERM "$worker_pid"
wait "$worker_pid"

In a local check for this article, a PHP 8.4.27 CLI process with PCNTL printed the start and finish markers for the active simulated job, then the worker's stopped marker; it did not print the next job's start marker. That result verifies the small demonstration on the checked environment, not every queue client or PHP deployment.

After the isolated loop works, test the actual service in a staging or otherwise disposable queue. Useful observations include the journal around stop, the process exit status, the reservation state, and whether the same job is delivered again after a forced kill. Also test a dependency timeout that is shorter than TimeoutStopSec=. A happy-path SIGTERM test alone cannot reveal what happens when DNS, a database, or an HTTP endpoint stalls.

A few failure patterns deserve explicit attention:

  • calling exit inside the handler can bypass the active job's ordinary boundary;
  • performing complex cleanup inside the handler creates competing control flow;
  • reserving batches makes "stop after the current job" ambiguous unless unstarted reservations can be released or expire;
  • an unlimited I/O call can consume the entire stop deadline;
  • a deadline shorter than a normal job guarantees periodic forced termination;
  • a deadline much longer than any acceptable job can hide a stuck worker during deployment.

Graceful means cooperative and bounded

A small PHP worker does not need an elaborate shutdown framework to cooperate with systemd. It needs a clear job boundary, a minimal signal handler, a loop that stops claiming work, finite operations, and a stop deadline chosen from the behavior of real jobs. Those pieces make routine stops less destructive and easier to observe.

They do not remove the need for leases, retries, idempotency, or recovery after a hard failure. That limitation is not a defect in the pattern; it is the boundary that keeps the promise honest. The practical design question is not “Can this worker always finish?” but “When it cannot finish, can the queue safely explain and recover what remains?”

References