Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src/Durable/Activity/ActivityContractResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ final class ActivityContractResolver
private const CACHE_PREFIX = 'durable.activity_contract.';
private const CACHE_TTL = 3600;

/**
* Les métadonnées déjà résolues dans ce processus.
*
* Elles dérivent des attributs, donc du code : elles ne peuvent pas changer tant que le
* processus vit. Sans cette mémoire, un résolveur sans pool — et le pool est `null` par défaut —
* refait la réflexion à chaque appel d'activité, et un résolveur avec pool refait un
* aller-retour au pool, qui sur un Redis est un aller-retour réseau.
*
* @var array<class-string, array<string, string>>
*/
private array $resolved = [];

public function __construct(
private readonly ?CacheItemPoolInterface $cache = null,
) {}
Expand All @@ -29,12 +41,16 @@ public function __construct(
*/
public function resolveActivityMethods(string $contractClass): array
{
if (isset($this->resolved[$contractClass])) {
return $this->resolved[$contractClass];
}

$cacheKey = self::CACHE_PREFIX . str_replace('\\', '_', $contractClass);

if (null !== $this->cache) {
$item = $this->cache->getItem($cacheKey);
if ($item->isHit()) {
return $item->get();
return $this->resolved[$contractClass] = $item->get();
}
}

Expand All @@ -47,7 +63,7 @@ public function resolveActivityMethods(string $contractClass): array
$this->cache->save($item);
}

return $result;
return $this->resolved[$contractClass] = $result;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace Gplanchat\Durable\Bundle\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* Dit ce qu'il faut configurer quand le verrou de reprise n'a pas de fabrique.
*
* Le backend DBAL n'a pas de serveur pour sérialiser les tâches d'une même exécution : c'est
* `SingleResumeLockMiddleware` qui s'en charge, et sans lui deux workers rejouent le même journal
* en même temps. Sa fabrique est prise dans le conteneur de l'application.
*
* Sans `framework.lock`, ce service n'existe pas et la compilation échoue déjà — sur un « service
* inexistant » qui nomme `lock.factory` et laisse chercher. Ce que l'exploitant doit savoir n'est
* pas quel service manque, mais quelle section de configuration l'aurait posé, et pourquoi elle
* n'est pas optionnelle ici.
*
* Vérifié dans une passe et non dans l'extension : au moment où les extensions se chargent, celle
* qui pose `lock.factory` n'a pas forcément tourné, et un test d'existence y répondrait faux pour
* une application correctement configurée.
*/
final class RequireLockFactoryPass implements CompilerPassInterface
{
private const LOCK_SERVICE = 'durable.dbal.single_resume_lock';

public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition(self::LOCK_SERVICE)) {
return;
}

// Le service peut avoir été redéfini sans argument par l'application ; on retombe alors sur
// le nom conventionnel plutôt que d'échouer sur la lecture de l'argument.
$arguments = $container->getDefinition(self::LOCK_SERVICE)->getArguments();
$factory = (string) ($arguments[0] ?? 'lock.factory');

if ($container->has($factory)) {
return;
}

throw new \LogicException(\sprintf(
'durable: le backend DBAL sérialise les reprises d\'une même exécution avec un verrou, '
. 'et le service "%s" qui le fournit n\'existe pas. Activez le composant Lock — '
. '`framework.lock: true` dans config/packages/framework.yaml, ou une entrée `framework.lock.resources` '
. 'pointant un magasin partagé entre vos processus — ou nommez votre propre fabrique dans '
. '`durable.dbal.lock_factory`. Sans verrou, deux workers rejouent le même journal en même temps.',
$factory,
));
}
}
9 changes: 6 additions & 3 deletions src/DurableBundle/DependencyInjection/DurableExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -500,9 +500,12 @@ private function registerActivityContractResolver(ContainerBuilder $container, a
{
$activityConfig = $config['activity_contracts'] ?? [];
$cacheId = $activityConfig['cache'] ?? null;
$cacheRef = null !== $cacheId && $container->hasDefinition($cacheId)
? new Reference($cacheId)
: null;
// Pas de `hasDefinition()` ici : un alias n'en est pas une — `Psr\Cache\CacheItemPoolInterface`
// en est un — et une définition posée par une extension qui tourne après celle-ci n'en est
// pas encore une. Le test rendait donc faux pour des configurations parfaitement valides, et
// le pool demandé était jeté sans un mot. Référencer sans condition rend l'erreur au
// compilateur, qui sait dire quel service manque.
$cacheRef = null !== $cacheId ? new Reference($cacheId) : null;

$container->register(ActivityContractResolver::class, ActivityContractResolver::class)
->setArguments([$cacheRef])
Expand Down
5 changes: 5 additions & 0 deletions src/DurableBundle/DurableBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Gplanchat\Durable\Bundle\DependencyInjection\Compiler\DurableTemporalTransportFactoryPass;
use Gplanchat\Durable\Bundle\DependencyInjection\Compiler\NexusHandlerPass;
use Gplanchat\Durable\Bundle\DependencyInjection\Compiler\RegisterDurableMiddlewarePass;
use Gplanchat\Durable\Bundle\DependencyInjection\Compiler\RequireLockFactoryPass;
use Gplanchat\Durable\Bundle\DependencyInjection\Compiler\WorkflowPass;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
Expand Down Expand Up @@ -56,6 +57,10 @@ static function (ChildDefinition $definition, FulfilsNexusOperation $attribute,
$container->addCompilerPass(new ActivityHandlerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 50);
// Même priorité, même raison : après l'autoconfiguration par attribut, avant les passes à 0.
$container->addCompilerPass(new NexusHandlerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 50);
// Après l'enregistrement des services DBAL, avant que le conteneur ne se plaigne d'un
// service inexistant : le message de la passe dit quoi configurer, pas seulement quoi manque.
$container->addCompilerPass(new RequireLockFactoryPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 20);

// Après tous les passes d'autowiring : injecte TemporalActivityWorker dans TemporalTransportFactory.
$container->addCompilerPass(new DurableTemporalTransportFactoryPass(), PassConfig::TYPE_BEFORE_REMOVING);
}
Expand Down
4 changes: 3 additions & 1 deletion src/DurableBundle/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
"suggest": {
"phpunit/phpunit": "Required by the shipped test helper (Testing\\DurableBundleTestTrait)",
"symfony/framework-bundle": "Required by consumers of the shipped test helper (Testing\\DurableBundleTestTrait), whose static::getContainer() resolves against a KernelTestCase",
"symfony/web-profiler-bundle": "Web Debug Toolbar and profiler: Durable panel (workflows / activities)"
"symfony/web-profiler-bundle": "Web Debug Toolbar and profiler: Durable panel (workflows / activities)",
"gplanchat/durable-bridge-temporal": "Temporal backend: DurableExtension wires its client, stores and worker transports when durable.temporal.dsn is set",
"gplanchat/durable-bridge-dbal": "SQL backend: DurableExtension wires its journal, stores and resume lock when event_store.type is dbal"
},
"autoload": {
"psr-4": {
Expand Down
92 changes: 92 additions & 0 deletions tests/unit/Durable/Activity/ActivityContractResolverMemoTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

namespace unit\Gplanchat\Durable\Activity;

use Gplanchat\Durable\Activity\ActivityContractResolver;
use Gplanchat\Durable\Attribute\AsActivity;
use Gplanchat\Durable\Attribute\AsActivityMethod;
use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;

#[AsActivity(name: 'memo')]
interface MemoContract
{
#[AsActivityMethod(name: 'faire')]
public function faire(string $quoi): string;
}

/**
* Le résolveur est sur le chemin chaud : chaque appel d'activité passe par lui.
*
* Deux coûts s'y cachaient. Sans pool — et le pool est `null` par défaut — il refaisait la
* réflexion à chaque appel. Avec un pool, il refaisait un aller-retour au pool à chaque appel, ce
* qui sur un Redis est un aller-retour réseau pour une donnée dérivée du code, donc immuable dans
* le processus.
*/
final class ActivityContractResolverMemoTest extends TestCase
{
public function testLeSecondAppelNInterrogePasLePool(): void
{
$pool = new class implements CacheItemPoolInterface {
public int $getItemCalls = 0;
/** @var array<string, mixed> */
private array $values = [];

public function getItem(string $key): CacheItemInterface
{
++$this->getItemCalls;
$values = &$this->values;

return new class($key, $values) implements CacheItemInterface {
/** @param array<string, mixed> $values */
public function __construct(private string $key, private array &$values) {}
public function getKey(): string { return $this->key; }
public function get(): mixed { return $this->values[$this->key] ?? null; }
public function isHit(): bool { return \array_key_exists($this->key, $this->values); }
public function set(mixed $value): static { $this->values[$this->key] = $value; return $this; }
public function expiresAt(?\DateTimeInterface $expiration): static { return $this; }
public function expiresAfter(\DateInterval|int|null $time): static { return $this; }
};
}

public function getItems(array $keys = []): iterable { return []; }
public function hasItem(string $key): bool { return false; }
public function clear(): bool { return true; }
public function deleteItem(string $key): bool { return true; }
public function deleteItems(array $keys): bool { return true; }
public function save(CacheItemInterface $item): bool { return true; }
public function saveDeferred(CacheItemInterface $item): bool { return true; }
public function commit(): bool { return true; }
};

$resolver = new ActivityContractResolver($pool);

$premier = $resolver->resolveActivityMethods(MemoContract::class);
$appresLePremier = $pool->getItemCalls;
$second = $resolver->resolveActivityMethods(MemoContract::class);

self::assertSame($premier, $second);
self::assertSame(
$appresLePremier,
$pool->getItemCalls,
'une donnée dérivée du code ne change pas dans le processus : le second appel doit être servi de mémoire',
);
}

public function testSansPoolLeResultatResteLeMeme(): void
{
$resolver = new ActivityContractResolver();

self::assertSame(
['faire' => 'memo.faire'],
$resolver->resolveActivityMethods(MemoContract::class),
);
self::assertSame(
['faire' => 'memo.faire'],
$resolver->resolveActivityMethods(MemoContract::class),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ private function middlewareOfBusAfterCompilation(array $config, array $existing
$container->register('messenger.bus.default')->addTag('messenger.bus');
$container->setParameter('messenger.bus.default.middleware', $existing);

// Et ce que FrameworkExtension pose quand `framework.lock` est configuré. Le backend DBAL
// l'exige : `RequireLockFactoryPass` refuse désormais un conteneur qui monte le verrou de
// reprise sans fabrique, parce qu'une application dans cet état ne démarrerait pas non plus.
$container->register('lock.factory', \stdClass::class);

(new DurableBundle())->build($container);
foreach ($container->getCompilerPassConfig()->getBeforeOptimizationPasses() as $pass) {
$pass->process($container);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

declare(strict_types=1);

namespace unit\Gplanchat\DurableBundle\DependencyInjection;

use Gplanchat\Durable\Activity\ActivityContractResolver;
use Gplanchat\Durable\Bundle\DependencyInjection\Compiler\RequireLockFactoryPass;
use Gplanchat\Durable\Bundle\DependencyInjection\DurableExtension;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;

/**
* Ce que le bundle accepte alors qu'il ne peut pas le tenir.
*
* Trois formes du même défaut : une configuration reçue puis jetée, une dépendance dont l'absence
* ne dit pas quoi faire, et une référence dure vers un paquet que personne ne déclare. Aucune ne
* casse à l'installation — toutes se paient au premier incident, là où on ne cherche pas.
*/
final class DurableDeclaredWiringTest extends TestCase
{
/**
* Le pool est cherché par `hasDefinition()`. Un alias — et
* `Psr\Cache\CacheItemPoolInterface` en est un — n'en est pas une, et une définition posée par
* une extension qui tourne après celle-ci n'en est pas encore une. Dans les deux cas la
* configuration de l'exploitant était silencieusement jetée.
*/
public function testUnPoolConfigureEstCableMemeSaDefinitionAbsenteAuChargement(): void
{
$container = $this->load(['activity_contracts' => ['cache' => 'mon.pool.declare.plus.tard']]);

$argument = $container->getDefinition(ActivityContractResolver::class)->getArgument(0);

self::assertInstanceOf(
Reference::class,
$argument,
"le pool configuré doit être référencé ; s'il n'existe pas, c'est une erreur de compilation, pas un silence",
);
self::assertSame('mon.pool.declare.plus.tard', (string) $argument);
}

public function testSansPoolConfigureLeResolveurNEnRecoitAucun(): void
{
$container = $this->load([]);

self::assertNull($container->getDefinition(ActivityContractResolver::class)->getArgument(0));
}

/**
* Sans `framework.lock`, `lock.factory` n'existe pas et le conteneur échoue — mais sur un
* « service inexistant » qui ne dit pas quoi configurer. Le verrou est obligatoire sur DBAL :
* sans lui, deux workers rejouent le même journal en même temps.
*/
public function testLAbsenceDeLockFactoryDitQuoiConfigurer(): void
{
$container = new ContainerBuilder();
$container->setDefinition('durable.dbal.single_resume_lock', new Definition(\stdClass::class))
->setArguments([new Reference('lock.factory')]);

$this->expectException(\LogicException::class);
$this->expectExceptionMessageMatches('/framework\.lock/');

(new RequireLockFactoryPass())->process($container);
}

public function testAvecLockFactoryLaPasseLaisseFaire(): void
{
$container = new ContainerBuilder();
$container->setDefinition('durable.dbal.single_resume_lock', new Definition(\stdClass::class));
$container->setDefinition('lock.factory', new Definition(\stdClass::class));

(new RequireLockFactoryPass())->process($container);

self::assertTrue($container->hasDefinition('durable.dbal.single_resume_lock'));
}

public function testSansBackendDbalLaPasseNeDitRien(): void
{
$container = new ContainerBuilder();

(new RequireLockFactoryPass())->process($container);

self::assertFalse($container->hasDefinition('lock.factory'));
}

/**
* L'extension importe des classes des deux ponts. Un `composer require` du seul bundle donne
* alors un conteneur qui compile et un fatal « class not found » au premier appel.
*/
public function testLesDeuxPontsSontDeclaresEnSuggest(): void
{
$manifest = json_decode(
(string) file_get_contents(__DIR__ . '/../../../../src/DurableBundle/composer.json'),
true,
);

self::assertIsArray($manifest);
$suggest = $manifest['suggest'] ?? [];

foreach (['gplanchat/durable-bridge-temporal', 'gplanchat/durable-bridge-dbal'] as $bridge) {
self::assertArrayHasKey($bridge, $suggest, $bridge . ' est câblé en dur par DurableExtension');
}
}

/**
* @param array<string, mixed> $config
*/
private function load(array $config): ContainerBuilder
{
$container = new ContainerBuilder();
(new DurableExtension())->load([$config], $container);

return $container;
}
}
Loading