Using Supervisor to handle a Symfony Command execution

icolomina

Nacho Colomina Torregrosa

Posted on September 6, 2024

Using Supervisor to handle a Symfony Command execution

Introduction

In this post we are going to learn how to use supervisord to handle the execution of a symfony command. Basically, supervisord will allow us to:

  • Autostart the command
  • Autorestart the command
  • Specify the number of processes we want supervisor to start.

The Problem

Sometimes we resort to the unix crontab to automate the execution of processes. This may work most of the time but there can be situations where it can cause problems.

Let's imagine we have a database table which logs users' notifications. The table stores the following information:

  • user
  • text
  • channel
  • status (WAITING, SENT)
  • createdAt
  • updatedAt

On the other hand, we have coded a command whose execution follows the next steps:

  • Queries the last WAITING notifications
  • Loops the queried notifications and:
    • Sends each one to the corresponding user.
    • Updates the notification status from WAITING to SENT

We set this command in the linux crontab to run every so often (1 minute, 2 minutes etc). So far so good.

Now let's imagine that the current process has queried 500 notifications and when it has sent 400, a new process starts. This means that the new process will query the 100 notifications that have not been updated yet by the last process plus the new ones:

Processes data overlap

This can cause those 100 notifications to be sent twice since both processes have queried them.

The Solution

As a solution, we can resort to using supervisor. It will keeps our process running and will restart it when required. This way, we only keep one process and avoid overlap. Let's analyze how the command should looks like:



#[AsCommand(
    name: 'app:notification'
)]
class NotificationCommand extends Command
{
    private bool $forceFinish = false;

    protected function configure(): void
    {
        $this
            ->addOption('time-limit', null, InputOption::VALUE_OPTIONAL, 'Max time alive in seconds')
            ->addOption('time-between-calls', null, InputOption::VALUE_OPTIONAL, 'Time between every loop call')

        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $this->forceFinish = false;
        pcntl_signal(SIGTERM, [$this, 'signalHandler']);
        pcntl_signal(SIGINT, [$this, 'signalHandler']);

        $timeLimit = $input->getOption('time-limit');
        $timeBetweenCalls = $input->getOption('time-between-calls');
        $dtMax = (new \DateTimeImmutable())->add(\DateInterval::createFromDateString("+ {$timeLimit} seconds"));

        do{
           // Here we should execute a service to query and send notifications
           // ......

           sleep($timeBetweenCalls);
           $dtCurrent = new \DateTimeImmutable();

        }while($dtCurrent < $dtMax && !$this->forceFinish);

        return Command::SUCCESS;
    }

    public function signalHandler(int $signalNumber): void
    {
        echo 'Signal catch: ' . $signalNumber . PHP_EOL;
        match ($signalNumber) {
            SIGTERM, SIGINT => $this->forceFinish = true,
            default => null
        };
    }
}



Enter fullscreen mode Exit fullscreen mode

Let's explain the command step by step:

  • The configure method declares to input options:

    • time-limit: Max time the command process can be alive. After that, it will finish and supervisor will restart it.
    • time-between-calls: Time to sleep after each loop iteration. The loop calls the service which processes notifications and then sleeps during such time.
  • The execute method behaves as follows:

    • Sets the forceFinish class variable to true
    • Uses the PHP pnctl library to register the method signalHandler for handling the Unix SIGTERM and SIGINT signals.
    • Gets the input options values and calculates the max date that the command can be alive until using the time-limit option value.
    • The do-while loop performs the required code to get the notifications and send them (it is not placed in the command, there are comments instead). Then, it sleeps the time established by the time-between-calls option before continuing.
    • If the current date (which is calculated in every loop iteration) is lower than the max date and the forceFinish is false, the loop continues. Otherwise the command finishes.
  • The signalHandler function catches the SIGTERM and SIGINT Unix signals. SIGINT is the signal sent when we press Ctrl+C and SIGTERM is the default signal when we use the kill command. When the signalHandler function detects them, it sets the forceFinish variable to true so that, when the current loop finishes, the command will finish since the forceFinish variable is no longer false. This allows users to terminate the process without having to wait until the max date is finished.

Configuring Supervisor

So far, we have been created the command. Now it's time to setup supervisor so it can handle it. Before starting with the configuration, we must install supervisor. You can do it running the following command:



sudo apt update && sudo apt install supervisor


Enter fullscreen mode Exit fullscreen mode

After installing, you can ensure supervisor is running by executing the next command:



sudo systemctl status supervisor


Enter fullscreen mode Exit fullscreen mode

Supervisor configuration files are placed in the following folder: /etc/supervisor/conf.d. Let's create a file named notif.conf and paste the following content:



command=php <your_project_folder>/bin/console app:notifications --time-limit=120 --time-between-calls=10
user=<your_user>
numprocs=1
autostart=true
autorestart=true
process_name=%(program_name)s_%(process_num)02d


Enter fullscreen mode Exit fullscreen mode

Let's explain each key:

  • command: The command to start
  • user: The unix user which runs the command
  • numprocs: The number of processes to run
  • autostart: Whether to autostart command
  • autostart: Whether to autorestart command
  • process_name: The command unix process name format.

With this configuration, the app:notifications command will be running for a maximum of 120 seconds and, it will sleep during 10 seconds after every loop. After passing 120 seconds or caching a unix signal, the command will exit the loop and finish. Then, supervisor will start it again.

Conclusion

We have been learned how to use supervisor to keep a command running without having to use the crontab. This can be useful when the processes launched by the crontab may overlap, causing data corruption.

In the last book I wrote, i show how to use supervisor to keep the symfony messenger workers running. If you want to know more, you can find the book here: Building an Operation-Oriented Api using PHP and the Symfony Framework: A step-by-step guide

💖 💪 🙅 🚩
icolomina
Nacho Colomina Torregrosa

Posted on September 6, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related