Description
Symfony version(s) affected
6.4.0
Description
When using the RedispatchMessage
in combination with the scheduler and the symfony serializer (with JSON), then the messenger fails while trying to deserialize the message. This is due to the MessageContext
class which expects the TriggerInterface
for the $trigger
property. When the serializer serialized the message to JSON, the information which implementation of the trigger is used, is lost.
I think we're missing a custom normalizer for the MessageContext
that is able to infer the correct implementation depending on the content.
This is not a problem when the default PHP serialization is used as the relevant class information is backed into the serialized content. I guess that's also the reason that this issue didn't come up yet.
How to reproduce
Create a schedule with any message that transfers to a different transport (so that the message is written to a message queue).
use Symfony\Component\Messenger\Message\RedispatchMessage;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
#[AsSchedule('default')]
final readonly class ScheduleProvider implements ScheduleProviderInterface
{
#[\Override]
public function getSchedule(): Schedule
{
$schedule = new Schedule();
$schedule
->add(RecurringMessage::every(
'5 seconds', // Just for easier testing
new RedispatchMessage(
new DeleteOldNotificationsMessage(), // Class is in same directory
'async',
),
));
return $schedule;
}
}
Configure the messenger to use the Symfony serializer like shown in the documentation:
return static function (FrameworkConfig $framework, ContainerConfigurator $container) {
$messenger = $framework->messenger();
// -- Serializer
$messenger
->serializer()
->defaultSerializer('messenger.transport.symfony_serializer')
->symfonySerializer()
->format('json');
// Async - All low priority tasks
$messenger
->transport('async')
->dsn('doctrine://default')
->options([
'queue_name' =>'async',
]);
;
...
Make sure to run the async messenger queue and at the then start the scheduler:
php bin/console messenger:consume -v scheduler_default
Then the job queue will fail with the following error stack:
In Serializer.php line 127:
2024-01-17T12:13:27.950138967Z
2024-01-17T12:13:27.950143217Z [Symfony\Component\Messenger\Exception\MessageDecodingFailedException]
2024-01-17T12:13:27.950146133Z Could not decode stamp: The type of the "trigger" attribute for class "Symf
2024-01-17T12:13:27.950148925Z ony\Component\Scheduler\Generator\MessageContext" must be one of "Symfony\C
2024-01-17T12:13:27.950151717Z omponent\Scheduler\Trigger\TriggerInterface" ("array" given).
2024-01-17T12:13:27.950154467Z
2024-01-17T12:13:27.950157092Z
2024-01-17T12:13:27.950159425Z Exception trace:
2024-01-17T12:13:27.950161800Z at /var/www/html/vendor/symfony/messenger/Transport/Serialization/Serializer.php:127
2024-01-17T12:13:27.950164300Z Symfony\Component\Messenger\Transport\Serialization\Serializer->decodeStamps() at /var/www/html/vendor/symfony/messenger/Transport/Serialization/Serializer.php:72
2024-01-17T12:13:27.950167675Z Symfony\Component\Messenger\Transport\Serialization\Serializer->decode() at /var/www/html/vendor/symfony/doctrine-messenger/Transport/DoctrineReceiver.php:138
2024-01-17T12:13:27.950170467Z Symfony\Component\Messenger\Bridge\Doctrine\Transport\DoctrineReceiver->createEnvelopeFromData() at /var/www/html/vendor/symfony/doctrine-messenger/Transport/DoctrineReceiver.php:65
2024-01-17T12:13:27.950173467Z Symfony\Component\Messenger\Bridge\Doctrine\Transport\DoctrineReceiver->get() at /var/www/html/vendor/symfony/doctrine-messenger/Transport/DoctrineTransport.php:42
2024-01-17T12:13:27.950176300Z Symfony\Component\Messenger\Bridge\Doctrine\Transport\DoctrineTransport->get() at /var/www/html/vendor/symfony/messenger/Worker.php:102
2024-01-17T12:13:27.950189550Z Symfony\Component\Messenger\Worker->run() at /var/www/html/vendor/symfony/messenger/Command/ConsumeMessagesCommand.php:238
2024-01-17T12:13:27.950192425Z Symfony\Component\Messenger\Command\ConsumeMessagesCommand->execute() at /var/www/html/vendor/symfony/console/Command/Command.php:326
2024-01-17T12:13:27.950195175Z Symfony\Component\Console\Command\Command->run() at /var/www/html/vendor/symfony/console/Application.php:1096
2024-01-17T12:13:27.950197842Z Symfony\Component\Console\Application->doRunCommand() at /var/www/html/vendor/symfony/framework-bundle/Console/Application.php:126
2024-01-17T12:13:27.950204300Z Symfony\Bundle\FrameworkBundle\Console\Application->doRunCommand() at /var/www/html/vendor/symfony/console/Application.php:324
2024-01-17T12:13:27.950207008Z Symfony\Component\Console\Application->doRun() at /var/www/html/vendor/symfony/framework-bundle/Console/Application.php:80
2024-01-17T12:13:27.950209592Z Symfony\Bundle\FrameworkBundle\Console\Application->doRun() at /var/www/html/vendor/symfony/console/Application.php:175
2024-01-17T12:13:27.950212175Z Symfony\Component\Console\Application->run() at /var/www/html/bin/console:40
Possible Solution
Having a custom normalizer that can denormalize the TriggerInterface
depending on the context. That of course would not solve the issue when someone implements their own trigger. But then it's "simple" to construct a custom normalizer for the project.
This is a simplified version of such a normalizer that specifically only handles PeriodicalTrigger
(which fixes my local problems):
final class TriggerDenormalizer implements DenormalizerInterface
{
/**
* @param array{
* intervalInSeconds: int,
* from: string,
* until: string,
* description: string,
* } $data
*/
public function denormalize($data, string $type, string $format = null, array $context = []): TriggerInterface
{
return new PeriodicalTrigger(
$data['intervalInSeconds'],
$data['from'],
$data['until'],
);
}
public function supportsDenormalization($data, string $type, string $format = null, array $context = []): bool
{
return $type === TriggerInterface::class;
}
public function getSupportedTypes(?string $format): array
{
return [
TriggerInterface::class => true,
];
}
}
Additional Context
The primary question I'm having is about the approach. Does this makes sense to have an additional normalizer? If so, does it make sense to extend the triggers with logic for normalization and denormalization? They are very flexible to use from the outside, but as a side effect not very "normalizable". The example I've added is a poor man version that doesn't cover a lot of the internals and I'm not sure whether with the current architecture it even could be handled from the outside or whether we need some kind of logic exposed to the outside specific to normalization.
Would ask for direction here.