From bb8fddf5f80686c078ff9067db545a892331ad72 Mon Sep 17 00:00:00 2001 From: LounisBou Date: Wed, 1 Jul 2026 15:18:50 +0200 Subject: [PATCH 1/4] perf: replace ObjectNormalizer with a hand-rolled Redis (de)serializer --- src/Hub/Transport/Redis/RedisSerializer.php | 51 ++++++++++--- .../Transport/Redis/RedisSerializerTest.php | 71 +++++++++++++++++++ 2 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php diff --git a/src/Hub/Transport/Redis/RedisSerializer.php b/src/Hub/Transport/Redis/RedisSerializer.php index b28680f..030ac4e 100644 --- a/src/Hub/Transport/Redis/RedisSerializer.php +++ b/src/Hub/Transport/Redis/RedisSerializer.php @@ -4,26 +4,55 @@ namespace Freddie\Hub\Transport\Redis; +use Freddie\Message\Message; use Freddie\Message\Update; -use Symfony\Component\Serializer\Encoder\JsonEncoder; -use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; -use Symfony\Component\Serializer\Serializer; -use Symfony\Component\Serializer\SerializerInterface; +use function json_decode; +use function json_encode; + +use const JSON_THROW_ON_ERROR; + +/** + * Hand-rolled (de)serializer for the Redis transport. + * + * The wire shape is intentionally identical to the previous Symfony + * ObjectNormalizer output so mixed hub versions stay interoperable during a + * rolling deploy; only the reflection-heavy normalizer is dropped, since this + * runs on every message on every worker. + */ final readonly class RedisSerializer { - public function __construct( - private SerializerInterface $serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]), - ) { - } - public function serialize(Update $update): string { - return $this->serializer->serialize($update, 'json'); + $message = $update->message; + + return json_encode([ + 'topics' => $update->topics, + 'message' => [ + 'id' => $message->id, + 'data' => $message->data, + 'private' => $message->private, + 'event' => $message->event, + 'retry' => $message->retry, + ], + ], JSON_THROW_ON_ERROR); } public function deserialize(string $payload): Update { - return $this->serializer->deserialize($payload, Update::class, 'json'); + /** @var array{topics: string[], message: array{id: string, data: string|null, private: bool, event: string|null, retry: int|null}} $data */ + $data = json_decode($payload, true, flags: JSON_THROW_ON_ERROR); + $message = $data['message']; + + return new Update( + $data['topics'], + new Message( + id: $message['id'], + data: $message['data'], + private: $message['private'], + event: $message['event'], + retry: $message['retry'], + ), + ); } } diff --git a/tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php b/tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php new file mode 100644 index 0000000..1283c7e --- /dev/null +++ b/tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php @@ -0,0 +1,71 @@ +deserialize($serializer->serialize($update)); + + expect($result->topics)->toBe(['/foo', '/bar']); + expect($result->message->id)->toBe('01ARZ3'); + expect($result->message->data)->toBe("line1\nline2"); + expect($result->message->private)->toBeTrue(); + expect($result->message->event)->toBe('ping'); + expect($result->message->retry)->toBe(3000); +}); + +it('round-trips an update with null message fields and preserves the id', function () { + $serializer = new RedisSerializer(); + $update = new Update('/foo', new Message(id: '01BX5Z')); + + $result = $serializer->deserialize($serializer->serialize($update)); + + expect($result->topics)->toBe(['/foo']); + expect($result->message->id)->toBe('01BX5Z'); + expect($result->message->data)->toBeNull(); + expect($result->message->private)->toBeFalse(); + expect($result->message->event)->toBeNull(); + expect($result->message->retry)->toBeNull(); +}); + +// Guarantees mixed old/new hubs interoperate during a rolling deploy (Redis +// pub/sub has no format versioning), by cross-checking against the previous +// Symfony ObjectNormalizer wire format. +it('stays wire-compatible with the Symfony ObjectNormalizer format', function () { + $serializer = new RedisSerializer(); + $objectNormalizer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]); + $update = new Update(['/foo'], new Message(id: '01CX', data: 'hi', private: true, event: 'e', retry: 1)); + + // new serialize -> old deserialize + /** @var Update $fromNew */ + $fromNew = $objectNormalizer->deserialize($serializer->serialize($update), Update::class, 'json'); + expect($fromNew->topics)->toBe(['/foo']); + expect($fromNew->message->id)->toBe('01CX'); + expect($fromNew->message->data)->toBe('hi'); + expect($fromNew->message->private)->toBeTrue(); + expect($fromNew->message->event)->toBe('e'); + expect($fromNew->message->retry)->toBe(1); + + // old serialize -> new deserialize + $fromOld = $serializer->deserialize($objectNormalizer->serialize($update, 'json')); + expect($fromOld->topics)->toBe(['/foo']); + expect($fromOld->message->id)->toBe('01CX'); + expect($fromOld->message->data)->toBe('hi'); + expect($fromOld->message->private)->toBeTrue(); + expect($fromOld->message->event)->toBe('e'); + expect($fromOld->message->retry)->toBe(1); +}); From bedb99c075a77d8637e56017723ccc2f8829be33 Mon Sep 17 00:00:00 2001 From: LounisBou Date: Wed, 1 Jul 2026 17:13:08 +0200 Subject: [PATCH 2/4] fix: preserve deserialize error contract for malformed payloads --- src/Hub/Transport/Redis/RedisSerializer.php | 21 +++++++++----- .../Transport/Redis/RedisSerializerTest.php | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/Hub/Transport/Redis/RedisSerializer.php b/src/Hub/Transport/Redis/RedisSerializer.php index 030ac4e..bfbfc3c 100644 --- a/src/Hub/Transport/Redis/RedisSerializer.php +++ b/src/Hub/Transport/Redis/RedisSerializer.php @@ -6,6 +6,7 @@ use Freddie\Message\Message; use Freddie\Message\Update; +use UnexpectedValueException; use function json_decode; use function json_encode; @@ -40,18 +41,22 @@ public function serialize(Update $update): string public function deserialize(string $payload): Update { - /** @var array{topics: string[], message: array{id: string, data: string|null, private: bool, event: string|null, retry: int|null}} $data */ + /** @var array{topics?: string[], message?: array{id?: string|null, data?: string|null, private?: bool, event?: string|null, retry?: int|null}} $data */ $data = json_decode($payload, true, flags: JSON_THROW_ON_ERROR); - $message = $data['message']; + + // Required keys throw (as the previous ObjectNormalizer did); optional + // message fields fall back to their defaults (a missing id is regenerated). + $topics = $data['topics'] ?? throw new UnexpectedValueException('Malformed Mercure update: missing "topics".'); + $message = $data['message'] ?? throw new UnexpectedValueException('Malformed Mercure update: missing "message".'); return new Update( - $data['topics'], + $topics, new Message( - id: $message['id'], - data: $message['data'], - private: $message['private'], - event: $message['event'], - retry: $message['retry'], + id: $message['id'] ?? null, + data: $message['data'] ?? null, + private: $message['private'] ?? false, + event: $message['event'] ?? null, + retry: $message['retry'] ?? null, ), ); } diff --git a/tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php b/tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php index 1283c7e..c144c52 100644 --- a/tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php +++ b/tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php @@ -4,12 +4,14 @@ namespace Freddie\Tests\Unit\Hub\Transport\Redis; +use ErrorException; use Freddie\Hub\Transport\Redis\RedisSerializer; use Freddie\Message\Message; use Freddie\Message\Update; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; +use UnexpectedValueException; it('round-trips an update with all message fields', function () { $serializer = new RedisSerializer(); @@ -69,3 +71,30 @@ expect($fromOld->message->event)->toBe('e'); expect($fromOld->message->retry)->toBe(1); }); + +// Matches the previous ObjectNormalizer contract: optional message fields fall +// back to their defaults (a missing id is regenerated) WITHOUT emitting a warning. +it('tolerates missing optional message fields without emitting a warning', function () { + set_error_handler(static fn (int $errno, string $errstr) => throw new ErrorException($errstr), E_WARNING | E_NOTICE); + + try { + $update = (new RedisSerializer())->deserialize('{"topics":["/foo"],"message":{"data":"hi"}}'); + } finally { + restore_error_handler(); + } + + expect(strlen($update->message->id))->toBe(26); // a fresh Ulid was generated + expect($update->message->data)->toBe('hi'); + expect($update->message->private)->toBeFalse(); + expect($update->message->event)->toBeNull(); + expect($update->message->retry)->toBeNull(); +}); + +// Matches the previous ObjectNormalizer contract: required keys throw when absent. +it('throws when the payload is missing required topics', function () { + (new RedisSerializer())->deserialize('{"message":{"id":"01CX","data":"hi"}}'); +})->throws(UnexpectedValueException::class); + +it('throws when the payload is missing the message', function () { + (new RedisSerializer())->deserialize('{"topics":["/foo"]}'); +})->throws(UnexpectedValueException::class); From e52f8aa93b6192f33609055f69c4c0d9a570ed8e Mon Sep 17 00:00:00 2001 From: LounisBou Date: Wed, 1 Jul 2026 17:44:00 +0200 Subject: [PATCH 3/4] style: wrap lines exceeding 120 characters --- src/Hub/Transport/Redis/RedisSerializer.php | 6 ++++-- tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Hub/Transport/Redis/RedisSerializer.php b/src/Hub/Transport/Redis/RedisSerializer.php index bfbfc3c..7c0e7e0 100644 --- a/src/Hub/Transport/Redis/RedisSerializer.php +++ b/src/Hub/Transport/Redis/RedisSerializer.php @@ -46,8 +46,10 @@ public function deserialize(string $payload): Update // Required keys throw (as the previous ObjectNormalizer did); optional // message fields fall back to their defaults (a missing id is regenerated). - $topics = $data['topics'] ?? throw new UnexpectedValueException('Malformed Mercure update: missing "topics".'); - $message = $data['message'] ?? throw new UnexpectedValueException('Malformed Mercure update: missing "message".'); + $topics = $data['topics'] + ?? throw new UnexpectedValueException('Malformed Mercure update: missing "topics".'); + $message = $data['message'] + ?? throw new UnexpectedValueException('Malformed Mercure update: missing "message".'); return new Update( $topics, diff --git a/tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php b/tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php index c144c52..60c1425 100644 --- a/tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php +++ b/tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php @@ -75,7 +75,10 @@ // Matches the previous ObjectNormalizer contract: optional message fields fall // back to their defaults (a missing id is regenerated) WITHOUT emitting a warning. it('tolerates missing optional message fields without emitting a warning', function () { - set_error_handler(static fn (int $errno, string $errstr) => throw new ErrorException($errstr), E_WARNING | E_NOTICE); + set_error_handler( + static fn (int $errno, string $errstr) => throw new ErrorException($errstr), + E_WARNING | E_NOTICE, + ); try { $update = (new RedisSerializer())->deserialize('{"topics":["/foo"],"message":{"data":"hi"}}'); From edc1b7de2be47669cf9d92fce80e2c6030ea9b2e Mon Sep 17 00:00:00 2001 From: LounisBou Date: Mon, 6 Jul 2026 16:51:21 +0200 Subject: [PATCH 4/4] build: move symfony/serializer to require-dev The Redis transport no longer uses it at runtime; the wire-compat test keeps it as a dev dependency via a legacy_redis_serializer() helper. --- composer.json | 4 ++-- tests/Pest.php | 14 ++++++++++++++ .../Hub/Transport/Redis/RedisSerializerTest.php | 7 +++---- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index c1aa00d..04beb5e 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,6 @@ "symfony/property-access": "^6.0.0|^7.0.0|^8.0.0", "symfony/property-info": "^6.0.0|^7.0.0|^8.0.0", "symfony/runtime": "^6.0.0|^7.0.0|^8.0.0", - "symfony/serializer": "^6.0.0|^7.0.0|^8.0.0", "symfony/uid": "^6.0.0|^7.0.0|^8.0.0", "symfony/yaml": "^6.0.0|^7.0.0|^8.0.0" }, @@ -45,7 +44,8 @@ "ringcentral/psr7": "^1.3", "squizlabs/php_codesniffer": "^3.6", "symfony/http-client": "^6.0.0|^7.0.0|^8.0.0", - "symfony/process": "^6.0.0|^7.0.0|^8.0.0" + "symfony/process": "^6.0.0|^7.0.0|^8.0.0", + "symfony/serializer": "^6.0.0|^7.0.0|^8.0.0" }, "config": { "optimize-autoloader": true, diff --git a/tests/Pest.php b/tests/Pest.php index c4394d8..fe9ac1e 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -11,6 +11,9 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use ReflectionClass; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; function handle(App $app, ServerRequestInterface $request): ResponseInterface { @@ -46,6 +49,17 @@ function jwt_config(): Configuration ); } +/** + * The Symfony-based serializer previously used for the Redis transport, + * kept as a dev dependency to assert wire-format compatibility. + */ +function legacy_redis_serializer(): Serializer +{ + static $serializer; + + return $serializer ??= new Serializer([new ObjectNormalizer()], [new JsonEncoder()]); +} + function create_jwt(array $claims): string { $builder = jwt_config()->builder(); diff --git a/tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php b/tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php index 60c1425..1419352 100644 --- a/tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php +++ b/tests/Unit/Hub/Transport/Redis/RedisSerializerTest.php @@ -8,11 +8,10 @@ use Freddie\Hub\Transport\Redis\RedisSerializer; use Freddie\Message\Message; use Freddie\Message\Update; -use Symfony\Component\Serializer\Encoder\JsonEncoder; -use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; -use Symfony\Component\Serializer\Serializer; use UnexpectedValueException; +use function Freddie\Tests\legacy_redis_serializer; + it('round-trips an update with all message fields', function () { $serializer = new RedisSerializer(); $update = new Update( @@ -49,7 +48,7 @@ // Symfony ObjectNormalizer wire format. it('stays wire-compatible with the Symfony ObjectNormalizer format', function () { $serializer = new RedisSerializer(); - $objectNormalizer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]); + $objectNormalizer = legacy_redis_serializer(); $update = new Update(['/foo'], new Message(id: '01CX', data: 'hi', private: true, event: 'e', retry: 1)); // new serialize -> old deserialize