Creating a one api endpoint with PHP and Symfony (Part 5)

Nacho Colomina
4 min readDec 28, 2022

--

Sometimes we need to block certain parts of our code since their execution have to be atomic, such as a heavy calculation in which any other alteration in its processing could create inconsistencies.

In this part of the article, we will do some modifications to be able to ensure an operation is executed once per request. In order to achieve that, we’re going to use redis as a semaphore so that, every time a request acquire an operation execution, it can block it until it finishes or during an specified time.

If you have not read the other articles, you can find then here:

Part 1: https://medium.com/@ict80/creating-a-one-api-endpoint-with-php-and-symfony-d5f03d3141c0

Part 2: https://medium.com/@ict80/creating-a-one-api-endpoint-with-php-and-symfony-part-2-187604ffeb67

Part 3: https://medium.com/@ict80/creating-a-one-endpoint-api-with-php-and-symfony-part-3-8955325c5101

Part 4: https://medium.com/@ict80/creating-a-one-endpoint-api-with-php-and-symfony-part-4-9cfc0e2f9925

Before starting, let’s install symfony snc redis bundle so we can work with redis

composer require snc/redis-bundle

In order to use snc redis bundle, you have to install PhpRedis or Predis so that bundle can connect to redis. You can find more info in https://github.com/snc/SncRedisBundle

Now, we can start.

Setting your redis server

You can install your own redis server in your computer or use docker. Once you have your redis server working, you have to set the required environment variable:

###> snc/redis-bundle ###
# passwords that contain special characters (@, %, :, +) must be urlencoded
REDIS_URL=redis://127.0.0.1:6399
###< snc/redis-bundle ###

In the above snippet, we’ve installed a local redis working on port 6399. Now, we need to configure snc redis bundle. To achieve that, we must use package file packages/snc_redis.yaml

snc_redis:
clients:
default:
type: phpredis
alias: default
dsn: "%env(REDIS_URL)%"

As we can see, we’ve configured a redis client which will use PhpRedis to connect to redis server. Its alias will be default and it will connect to our local redis server. When symfony is loaded, it will create a service named snc_redis.default based on yaml configuration.

Now, we only have to bind a variable to that service so we can inject it in our services:

        bind:
$redisDefault: '@snc_redis.default'

Marking operations as lockables

In order to specify an operation behaves as a lockable resource, we’re going to create a PHP attribute. As we can remember from part 3 of this set of articles, PHP attributes allow developers to add metadata to classes, methods and properties. Let’s see how our new attribute looks like:

#[\Attribute]
class Resource
{
public function __construct(
public readonly string $name,
public readonly float $time = 300.0
){ }
}

This attribute holds two properties:

  • name: resource’s name (we will use it to acquire and release resources)
  • time: Time in seconds we want to lock a resource

Now, let’s create a service which will allow us to acquire and release resources:

class LockHandler
{
private \Redis $redisDefault;

public function __construct(\Redis $redisDefault)
{
$this->redisDefault = $redisDefault;
}

/**
* @throws \RedisException
*/
public function acquireResource(string $resourceName, ?float $ttl = null): bool
{
$isAcquired = (bool)$this->redisDefault->exists($resourceName);
if($isAcquired){
return false;
}

($ttl > 0)
? $this->redisDefault->setex($resourceName, $ttl, 1)
: $this->redisDefault->set($resourceName, 1)
;

return true;
}

/**
* @throws \RedisException
*/
public function releaseResource(string $resourceName): void
{
$this->redisDefault->del($resourceName);
}
}

LockHandler service contains two methods:

  • acquireResource: Receives resource name and ttl to lock. If resource is already acquired it returns false, otherwise it acquires resource creating the locking key in our redis server and returns true.
  • releaseResource: It simply receives a locked resource name and releases it by deleteing the locked key in redis. If the key does not already exists due to ttl expiration, this method does nothing.

Now we have LockHander service, let’s create an operation which would process a heavy calculation and mark it as resource.

#[IsBackground]
#[Resource(name: 'calculation', time: 180.0)]
class HeavyCalculationOperation implements ApiOperationInterface
{

public function perform(ApiInput $apiInput): ApiOutput
{
// Process a heavy calculation ......

// ........
return new ApiOutput([], Response::HTTP_OK);
}

public function getName(): string
{
return 'HeavyCalculationOperation';
}

......
}

HeavyCalculationOperation has a Resource attribute marked as name calculation and an specified ttl of 180 seconds. Now, let’s see how ApiOperationMessageHandler looks like:

#[AsMessageHandler]
class ApiOperationMessageHandler
{
public function __construct(
private readonly ApiOperationsCollection $apiOperationsCollection,
private readonly LockHandler $lockHandler,
private readonly MessageBusInterface $bus,
private readonly AttributeHelper $attributeHelper
){ }


/**
* @throws \RedisException
*/
public function __invoke(ApiOperationMessage $message): void
{

$apiInput = $message->getApiInput();
$operation = $this->apiOperationsCollection->getOperation($apiInput->getOperation());

$resource = $this->attributeHelper->getAttr($operation, Resource::class);
if($resource instanceof Resource){
$locked = $this->lockHandler->acquireResource($resource->name, $resource->time);
if(!$locked){
$delayTime = ($resource->time > 0) ? ($resource->time * 1000) + rand(5, 15) : 300 * 1000;
$this->bus->dispatch($message, [new DelayStamp($delayTime)]);
return;
}
}

$operation->perform($apiInput);
if($resource instanceof Resource){
$this->lockHandler->releaseResource($resource->name);
}
}
}

ApiOperationMessageHandler checks whether operation have a Resource attribute. If so, it tries to acquire resource. If resource is already acquired it will delay operation execution. Delay execution time will be calculated following this:

  • If resource specify a ttl, it will delay ttl seconds + random number of seconds between 5 and 15.
  • If resource did not specify ttl, it will delay five minutes.

If resource is not locked, resource will be acquired and operation executed. After execution, resource will be released.

There is a lock component https://symfony.com/doc/current/components/lock.html which could be useful for the aim of this article

And that’s all, now we can mark operations as lockables with simply decorating them with our Resource attribute.

--

--