fbpx

Perform a task after sending an HTTP response with Symfony

symfony_oimmei

Are you a web/server developer? Do you work in PHP, maybe with Symfony? Are you struggling with some of your projects because there are API calls that perform some heavy work and they end up having extremely long response times? Are you desperate because you have no idea how to finish your four pending projects before next week?

On the last thing unfortunately I can’t help you (and I can’t on the second one either), but I may have something to suggest about the third one.

While developing APIs, it’s not rare to get yourself into a situation where you have a call that triggers the execution of a really long and heavy task, but at the same time you can’t afford to make clients wait too much for a response. Often, though, this task doesn’t necessarily need to be done during the call itself. Think about an API that delivers push notifications: the client sends the notification to deliver and information to identify the target devices, but it’s not necessary to wait until you delivered to every device before sending the response. The same goes for emails delivery, or heavy data elaboration that doesn’t require interaction or feedback from the client.

If you already faced a similar challenge, you probably completed it by scheduling a task: you save the detail about the operation, you send the response and then a scheduled command handles the dirty work. But what if I told you that, with Symfony, there’s a better way that doesn’t require any additional configuration besides the one your application already has?

You probably already know about Symfony events and how to use them. I won’t go into the details here, but they are events the framework triggers during (not only) the handling of an HTTP request, and can be used to control the process. One in particular can provide a simple and elegant solution to our problem: the kernel.terminate event. This event is triggered after the response has been sent to the client. But how can it help us speeding up our API and making our clients happy?

Let’s find out with an example.

Our little test application will be developed in Symfony 4.4, the current LTS version. In other versions, starting from Symfony 3, what we see is very similar anyway.

So we have an API, with an endpoint /perform-heavy-task that – unsurprisingly – performs a heavy task, with a few log entries to keep an eye on it.

namespace App\Controller;

use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;


class HeavyTaskController extends AbstractController
{
    /**
     * @Route("/perform-heavy-task", name="perform_heavy_task", defaults={"_format": "json"})
     */
    public function performHeavyTask(LoggerInterface $logger)
    {
     $logger->info("Starting heavy task...");
     // Extremely important and heavy task.
     sleep(10);

     $logger->info("Sending response...");

     return $this->json(['message' => '*puff puff* Heavy task completed!']);
    }
}

As we can see from the log, in this simple setup the application behaves exactly as we can guess: the controller gets the call, the entire task gets completed and then the response is sent to the client. Not too exciting.

[2020-03-09 19:27:20] request.INFO: Matched route "perform_heavy_task"...
[2020-03-09 19:27:20] app.INFO: Starting heavy task... [] []
[2020-03-09 19:27:30] app.INFO: Sending response... [] []

But, it our task can be delayed, here’s kernel.terminate coming to our rescue.

Let’s create an event listener (a subscriber works too) which accepts a TerminateEvent object – that’s our event – and let’s move our really important task there. We’ll need the RouterInterface to be sure our task is actually performed after performHeavyTask: kernel.terminate is triggered after every HTTP request!

namespace App\EventListener;

use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\Routing\RouterInterface;

class HeavyTaskListener
{
      /**
       * @var RouterInterface
       */
      private $router;
     /**
      * @var LoggerInterface
      */
      private $logger;

      public function __construct(RouterInterface $router, LoggerInterface $logger)
     {
             $this->router = $router;
             $this->logger = $logger;
     }

     public function onKernelTerminate(TerminateEvent $event)
     {
            // What’s the current route?
            $currentRoute = $this->router->match($event->getRequest()->getPathInfo());
            if ('perform_heavy_task' === $currentRoute['_route']) {
                  // This is it: on with the task.
                  $this->logger->info("Starting heavy task...");

                 // Extremely important and heavy task.
                 sleep(10);

                 $this->logger->info("*puff puff* Heavy task completed!");

             }
      }
}

Let’s register the listener in services.yaml

    App\EventListener\HeavyTaskListener:
        arguments:
            - '@router'
            - '@logger'
        tags:
            - { name: kernel.event_listener, event: kernel.terminate }

…and edit the original action like this.

    public function performHeavyTask(LoggerInterface $logger)
    {
     $logger->info("Sending response...");

     return $this->json(['message' => 'About to perform the heavy task!']);
    }

Trying it out, we can immediately see the difference: this time, the response is sent immediately, and only after that the task is started and completed.

[2020-03-09 19:50:44] request.INFO: Matched route "perform_heavy_task"...
[2020-03-09 19:50:44] app.INFO: Sending response... [] []
[2020-03-09 19:50:44] app.INFO: Starting heavy task... [] []
[2020-03-09 19:50:54] app.INFO: *puff puff* Heavy task completed! [] []

Much better!

A few notes about this event.

  • As said, events from the HttpKernel component, such as kernel.terminate, are triggered during the handling of every request received by your application, so you need a way to “identify” the request in the listener. In our example we used route matching, but of course it’s not the only way. Another option is to use a custom service and inject it into both the controller and the listener to keep track of the status of the request, which is a great way to send data from the controller to the listener as well.
  • Being the response sent before the listener starts, the client has clearly no feedback if the delayed task fails. If it’s important to notify the client about errors, we’ll have to do it in a different way – by sending an email, for example.
  • At the time of writing, only the PHP-FPM server API is able to continue processing the request after the response’s been sent. If our server doesn’t have this feature, the listener will still run, but the response will be delivered only after the task is completed anyway, making the event useless.

Aside from these notes, we saw how kernel.terminate can help us speeding up our API client-side without having to rely on other softwares or services.

So let’s fill our clients with joy! Let’s move our heavy tasks into listeners! And don’t forget what should be any developer’s motto: if your work can be postponed, postpone it.

Andrea Cioni

Related Posts
Comment

Leave Your Comment

*

*