Skip to content

Conversation

@VincentLanglet
Copy link
Contributor

@VincentLanglet VincentLanglet commented Dec 16, 2025

Q A
Branch? 7.4
Bug fix? yes
New feature? no
Deprecations? no
Issues Fix #60659
License MIT

7.4 for bugfix since it requires the LockKeyNormalizer

@carsonbot

This comment was marked as outdated.

@carsonbot carsonbot changed the title Fix DeduplicateStamp serialization Fix DeduplicateStamp serialization Dec 16, 2025
@VincentLanglet VincentLanglet changed the title Fix DeduplicateStamp serialization [Messenger] Fix DeduplicateStamp serialization Dec 16, 2025
@VincentLanglet VincentLanglet changed the base branch from 8.1 to 7.4 December 16, 2025 00:29
@OskarStark OskarStark changed the title [Messenger] Fix DeduplicateStamp serialization [Messenger] Fix DeduplicateStamp serialization Dec 16, 2025
@OskarStark OskarStark modified the milestones: 8.1, 7.4 Dec 16, 2025
return $this->isOnlyDeduplicatedInQueue();
}

public function isOnlyDeduplicatedInQueue(): bool
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should be able to skip adding a new method by adding an annotation on the current one, isn't it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests are failing without this method.
If I tried something like #[SerializedName()] on the previous method it's ignored.
Did you have something else in mind ?

The ObjectNormalizer is just looking at method/property without checking attributes

protected function extractAttributes(object $object, ?string $format = null, array $context = []): array
{
if (\stdClass::class === $object::class) {
return array_keys((array) $object);
}
// If not using groups, detect manually
$attributes = [];
// methods
$class = ($this->objectClassResolver)($object);
$reflClass = new \ReflectionClass($class);
foreach ($reflClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflMethod) {
if (
0 !== $reflMethod->getNumberOfRequiredParameters()
|| $reflMethod->isStatic()
|| $reflMethod->isConstructor()
|| $reflMethod->isDestructor()
) {
continue;
}
$name = $reflMethod->name;
$attributeName = null;
// ctype_lower check to find out if method looks like accessor but actually is not, e.g. hash, cancel
if (match ($name[0]) {
'g' => str_starts_with($name, 'get') && isset($name[$i = 3]),
'h' => str_starts_with($name, 'has') && isset($name[$i = 3]),
'c' => str_starts_with($name, 'can') && isset($name[$i = 3]),
'i' => str_starts_with($name, 'is') && isset($name[$i = 2]),
default => false,
} && !ctype_lower($name[$i])) {
if ($this->hasProperty($reflMethod, $name)) {
$attributeName = $name;
} else {
$attributeName = substr($name, $i);
if (!$reflClass->hasProperty($attributeName)) {
$attributeName = lcfirst($attributeName);
}
}
}
if (null !== $attributeName && $this->isAllowedAttribute($object, $attributeName, $format, $context)) {
$attributes[$attributeName] = true;
}
}
// properties
foreach ($reflClass->getProperties() as $reflProperty) {
if (!$reflProperty->isPublic()) {
continue;
}
if ($reflProperty->isStatic() || !$this->isAllowedAttribute($object, $reflProperty->name, $format, $context)) {
continue;
}
$attributes[$reflProperty->name] = true;
}
return array_keys($attributes);
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there no way to split the Serializer concern from the exact API of the stamp? I doesn't feel right to me to add a deprecation to fit some external constraints.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand but currently I didn't find one.

If I start parsing Attribute with

new ObjectNormalizer(
                classMetadataFactory: new ClassMetadataFactory(new AttributeLoader()),
                propertyTypeExtractor: new ReflectionExtractor()
            ),

I get the following exception:

throw new MappingException(\sprintf('SerializedName on "%s::%s()" cannot be added. SerializedName can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name));

So in order to serialize stamps, they must respect some constraints.
This is documented here: https://symfonycasts.com/screencast/messenger/serializer-headers-options#shouldnt-we-always-use-the-symfony-serializer

The reason is that the Symfony serializer requires your classes to follow a few rules in order to be serialized and unserialized correctly - like each property needs a setter method or a constructor argument where the name matches the property name. If your class doesn't follow those rules, you can end up with a property that is set on the original object, but suddenly becomes null when it's read from the transport. No fun.

Copy link
Contributor Author

@VincentLanglet VincentLanglet Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked at the other stamps and all of them are either implementing NonSendableStampInterface or exposing correctly named getter for all the properties.

The DeduplicateStamp is the only one with an exception for onlyDeduplicateInQueue.

The deprecation shouldn't be a big deal since no-one except the Middleware should call the onlyDeduplicateInQueue method.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hum, won't this concern be fixed after #62772?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-answering: nope, but this still reminds us about the enable_getter_setter_extraction context option. Maybe that's the way to go?

Copy link
Contributor Author

@VincentLanglet VincentLanglet Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how you want to pass enable_getter_setter_extraction ; I tried multiple way without effect.

If you look at the ObjectNormalizer,

foreach ($reflClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflMethod) {
if (
0 !== $reflMethod->getNumberOfRequiredParameters()
|| $reflMethod->isStatic()
|| $reflMethod->isConstructor()
|| $reflMethod->isDestructor()
) {
continue;
}
$name = $reflMethod->name;
$attributeName = null;
// ctype_lower check to find out if method looks like accessor but actually is not, e.g. hash, cancel
if (match ($name[0]) {
'g' => str_starts_with($name, 'get') && isset($name[$i = 3]),
'h' => str_starts_with($name, 'has') && isset($name[$i = 3]),
'c' => str_starts_with($name, 'can') && isset($name[$i = 3]),
'i' => str_starts_with($name, 'is') && isset($name[$i = 2]),
default => false,
} && !ctype_lower($name[$i])) {
if ($this->hasProperty($reflMethod, $name)) {
$attributeName = $name;
} else {
$attributeName = substr($name, $i);
if (!$reflClass->hasProperty($attributeName)) {
$attributeName = lcfirst($attributeName);
}
}
}
if (null !== $attributeName && $this->isAllowedAttribute($object, $attributeName, $format, $context)) {
$attributes[$attributeName] = true;
}
}
// properties
foreach ($reflClass->getProperties() as $reflProperty) {
if (!$reflProperty->isPublic()) {
continue;
}
if ($reflProperty->isStatic() || !$this->isAllowedAttribute($object, $reflProperty->name, $format, $context)) {
continue;
}
$attributes[$reflProperty->name] = true;
}
, it only extract public method get/is/has/can and public property

As soon as I pass a classMetadataFactory with an attribute loader, I also encounter the exception because the method is not is/set/get/has/can

$accessorOrMutator = preg_match('/^(get|is|has|set|can)(.+)$/i', $method->name, $matches);

I'm far away to be expert with Serializer, so maybe you have some code suggestion (you can even try my branch, test is executable with ./phpunit src/Symfony/Component/Messenger/Tests/Transport/Serialization/SerializerTest.php --filter=testEncodedWithStampsIsDecodable) but so far I really don't see the way to avoid the method renaming @nicolas-grekas ; is it really a big deal ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. All those extraction rules are wild to me. Some parts accept only prefixed accessors, others are fine with same-name method/properties. I don't know the reasons, this is blurry to me...
What about a normalizer/denormalizer instead? That could help fix both the issues with Key and onlyDeduplicateInQueue, isn't it? For Key, I mean fix the issue on 7.3 without LockKeyNormalizer.

Copy link
Contributor Author

@VincentLanglet VincentLanglet Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All those extraction rules are wild to me. Some parts accept only prefixed accessors, others are fine with same-name method/properties. I don't know the reasons, this is blurry to me...

Honestly, to me too.

What about a normalizer/denormalizer instead? That could help fix both the issues with Key and onlyDeduplicateInQueue, isn't it? For Key, I mean fix the issue on 7.3 without LockKeyNormalizer.

While I understand the idea (and the why), isn't it kinda too-specific/overcomplicated:

I feel like we're searching for a complex solution to avoir renaming a method ; issue I would have avoid if I have thought about the Serializer when implementing the DeduplicateStamp.

But if it's ok to create a new Normalizer in 7.3, it would be better to backport the LockKeyNormalizer in 7.3 then

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Messenger] DeduplicateMiddleware using Symfony Serializer

4 participants