An Asynchronous Request Bundle for Symfony

Pond5 Technology Blog on 2021-10-14

In this post we describe how and why we created an asynchronous request Bundle for Symfony and decided to share it as open source.

How it all started

At Pond5 we have a PHP web application connected to a Postgres database. A lot of the time we need to write batches of updates to the DB and this needs to be done in away that does not disrupt our normal operation and minimizes impact to our users.

Usually these big updates are handled by scheduled scripts with some suitable delay between writes so we don’t overwhelm the database. This is cumbersome and not at all usable to handle user initiated or internal requests to update data.

We needed a generic way to update rows in the database at a configurable rate limit.

The challenge was the “configurable rate limit” part. Having a Symfony framework based REST API, the initial idea was to implement a rate limiter to our “write” requests. However, such solution would:

a) limit the number of requests, not the number of the DB writes

b) require implementing a retry mechanism on the client side

c) require an option to bypass the limiter

Going Asynchronous

We decided to try with asynchronous requests. The Symfony documentation contains the “Going Async” part using Symfony Messenger Component.

It resulted in the following PoC:

ItemController.php

<?php

class ItemController
{
// ...

    public function updateItemAction(ParamFetcherInterface $paramFetcher, Item $item, Request $request): Response
    {
        /* ### ASYNC FLOW BEGIN ### */
        if ($request->headers->get('X-Request-Async')) {
            $this->bus->dispatch(new AsyncRequestNotification($item->getId(), $paramFetcher->all()));
            return new Response(null, 202);
        }
        /* ### ASYNC FLOW END ### */

        $name = $paramFetcher->get('name');
        $description = $paramFetcher->get('description');

        if ($name) {
            $item->setName($name);
        }
        if ($description) {
            $item->setDescription($description);
        }

        $this->em->flush();

        return new Response(null, 204);
    }
}

It worked, however there were a couple of issues:

  1. Most of the logic from the controller/action would need to be repeated in the consumer (as it needs to do the same work).
  2. Async requests run a lot of code that is not necessary (resolve controller, instantiate controller, resolve arguments, call controller).
  3. Each “write” action would need to have a very similar code added (check if async, send message, return response).

To apply the async functionality to each “write” request, we used a combination of Symfony’s built-in events and sub requests:

  1. Create a listener for the kernel.requestevent to skip from request (1) to response (7) (see diagram below).
  2. Have the consumer make a sub request, so that the processing flow is the same as if it was a synchronous request.
Source: Symfony Docs

AsyncRequestListener.php

<?php

class AsyncRequestListener implements EventSubscriberInterface
{
// ...

    public function onKernelRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();
        if ($request->headers->get('X-Request-Async') && in_array($request->getMethod(), ['DELETE', 'PATCH', 'POST', 'PUT'])) {
            $this->bus->dispatch(new AsyncRequestNotification($request));
            $event->setResponse(new Response(null, Response::HTTP_ACCEPTED, ['Content-Type' => null]));
        }
    }
}

AsyncRequestNotificationHandler.php

<?php

class AsyncRequestNotificationHandler implements MessageHandlerInterface
{
// ...

    public function __invoke(AsyncRequestNotification $notification): void
    {
        $request = $notification->getRequest();
        $response = $this->kernel->handle($request, HttpKernelInterface::SUB_REQUEST);
    }
}

Final code in the bundle:

AsyncRequestBundle/AsyncRequestListener.php at main · PondFive/AsyncRequestBundle Contribute to PondFive/AsyncRequestBundle development by creating an account on GitHub.github.com

AsyncRequestBundle/AsyncRequestNotificationHandler.php at main · PondFive/AsyncRequestBundle Contribute to PondFive/AsyncRequestBundle development by creating an account on GitHub.github.com

Additionally, using the listener to bypass steps 2–6 in the diagram above, improved the performance of the asynchronous requests from ~9ms to ~7.2ms.

Usage:

curl.log

# synchronous request with 204 response
$ curl -i -X PATCH "http://example.org/items/123" -H "Content-Type: application/json" -d "{\"name\":\"TEST\"}"

HTTP/1.1 204 No Content
Date: Wed, 21 Oct 2015 07:28:00 GMT

# asynchronous request with 202 response
$ curl -i -X PATCH "http://example.org/items/123" -H "Content-Type: application/json" -d "{\"name\":\"TEST\"}" -H "X-Request-Async: 1"

HTTP/1.1 202 Accepted
Date: Wed, 21 Oct 2015 07:28:00 GMT

In addition to the AsyncRequestBundle, we added:

The final solution looks like this:

Sample application diagram

While working on the project, the following issues with the Symfony Messenger Component were raised and addressed:

[Messenger] TraceableMiddleware does not verify if Stopwatch event is started before stopping it ·… Tobion referenced this issue in symfony/http-kernel May 11, 2020 the toolbar and profiler panel disable to profiler…github.com

[Messenger] Doctrine transport requires sender and receiver to use the same time zone · Issue… You can't perform that action at this time. You signed in with another tab or window. You signed out in another tab or…github.com

When experimenting with Symfony Rate Limiter, the following issues were raised:

[RateLimiter] Fix wait duration for fixed window policy by jlekowski · Pull Request #42168 ·… Q A Branch? 5.4, 5.3, 5.2 Bug fix? yes New feature? no Deprecations? no Tickets Fix #... License MIT Doc PR…github.com

[RateLimiter] SlidingWindow to use microtime() instead of time() · Issue #42194 · symfony/symfony You can't perform that action at this time. You signed in with another tab or window. You signed out in another tab or…github.com

Conclusion and Next Steps

This approach works well to limit the number of writes going in to the database and is already in use by a few production services.

We can now send a large number of requests to be throttled in a configurable manner. With a low load, it happens virtually instantaneously.

See the Github repository for the project here.

GitHub - PondFive/AsyncRequestBundle This bundle allows sending requests to a Symfony Messenger transport to be handled later by a consumer. Make sure…github.com

We considered adding events (Symfony EventDispatcher Component) to the AsyncRequestBundle. Currently, asynchronous events can be determined by checking if the response status code is 202, or if the request is main/sub. DB write limiter is only registered when the consumer is started (WorkerStartedEvent). Having additional, bundle specific events would simplify that (with a concern of downgrading the performance).

We also want to extract the DB write rate limiter into a bundle, and make it Open Source as well.