Skip to content

Run upcasters only for the versions they were written for - #238

Open
DavidBadura wants to merge 1 commit into
3.0.xfrom
versioned-upcasters
Open

DavidBadura wants to merge 1 commit into
3.0.xfrom
versioned-upcasters

Conversation

@DavidBadura

@DavidBadura DavidBadura commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Upcasters have to find out the shape of the stored data themselves today, usually by checking which fields exist. If the version of the data is stored with it, they can rely on that instead. An upcaster now declares the version of the data it expects with from and returns the data of the next version:

#[UpcasterFor(ProfileCreated::class, from: 1)]
final class SplitNameUpcaster implements Upcaster
{
    public function upcast(ClassMetadata $metadata, array $data, array $context): array
    {
        $data['name'] = $data['firstName'] . ' ' . $data['lastName'];
        unset($data['firstName'], $data['lastName']);

        return $data;
    }
}

The CallbackUpcaster takes the version as third argument. The version of the hydrated data is passed per class in the context with UpcastExtension::VERSIONS, for example [ProfileCreated::class => 1]. A versioned upcaster only runs for data of its version or older, so data of the current version skips all of them. Without a version in the context the data is treated as up to date and only the upcasters without a version run, they keep running for every version as before. The version is keyed by class so it doesn't leak to nested objects, whose data belongs to the shape of the outer class.

The versioned upcasters of a class have to be registered in the order of their versions, otherwise the middleware throws UpcastersNotInVersionOrder when it is built. Running a chain in the wrong order would silently produce broken data.

What event-sourcing would need

In event-sourcing the version is a custom header of the message, so the stores keep it in custom_headers without a schema change. A sketch for 4.0 once it uses hydrator 3. New would be the version on #[Event] and in the event metadata, the EventVersionHeader, the EventVersionDecorator and the OPTION_EVENT_VERSION of the serializer. The version comes from the event attribute:

#[Event('profile.created', version: 2)]
final class ProfileCreated
{
}
use Patchlevel\EventSourcing\Attribute\Header;

#[Header('eventVersion')]
final class EventVersionHeader
{
    public function __construct(
        public readonly int $version,
    ) {
    }
}

A message decorator adds the header when the event is recorded, like the SplitStreamDecorator does with its header:

final class EventVersionDecorator implements MessageDecorator
{
    public function __construct(
        private readonly EventMetadataFactory $eventMetadataFactory,
    ) {
    }

    public function __invoke(Message $message): Message
    {
        $metadata = $this->eventMetadataFactory->metadata($message->event()::class);

        return $message->withHeader(new EventVersionHeader($metadata->version));
    }
}

The stores write the header with the other custom headers, nothing changes there. While loading, they have to deserialize the custom headers before the payload, today they do it afterwards, and pass the version on to the serializer. Events stored before the header existed have no header and count as version 1:

$customHeaders = $this->headersSerializer->deserialize($data['custom_headers']);
$version = 1;

foreach ($customHeaders as $header) {
    if ($header instanceof EventVersionHeader) {
        $version = $header->version;
    }
}

$event = $this->eventSerializer->deserialize(
    new SerializedEvent($data['event_name'], $data['event_payload']),
    [DefaultEventSerializer::OPTION_EVENT_VERSION => $version],
);

The DefaultEventSerializer hands the version to the hydrator. Without the option it doesn't pass a version at all, so the data counts as up to date. Defaulting to 1 there would run the upcasters on current data whenever an event is deserialized somewhere without its headers:

use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension;

public function deserialize(SerializedEvent $data, array $options = []): object
{
    $payload = $this->encoder->decode($data->payload, $options);
    $class = $this->eventRegistry->eventClass($data->name);

    $context = [
        self::CONTEXT_EVENT_NAME => $data->name,
        self::CONTEXT_EVENT_CLASS => $class,
    ];

    if (isset($options[self::OPTION_EVENT_VERSION])) {
        $context[UpcastExtension::VERSIONS] = [$class => $options[self::OPTION_EVENT_VERSION]];
    }

    return $this->hydrator->hydrate($class, $payload, $context);
}

The upcasters then target the event class with the version they upcast from, #[UpcasterFor(ProfileCreated::class, from: 1)].

An upcaster can declare the version of the data it expects with from, in the
UpcasterFor attribute or as argument of the CallbackUpcaster. The version of
the hydrated data is passed per class in the context with
UpcastExtension::VERSIONS. Versioned upcasters only run for data of their
version or older, without a version the data is treated as up to date.
Upcasters without a version run for every version as before.

The versioned upcasters of a class have to be registered in the order of their
versions, the middleware throws an exception otherwise.
@github-actions

Copy link
Copy Markdown

Hello 👋

Here are the most recent benchmark results, compared against 3.0.x. Time is the mode over the iterations in the its column, with the relative standard deviation in brackets.

GeneratedHydrator

subject revs its memory base current diff
benchHydrate1Object 1000 5 1.855mb -21.23% 0.809μs (±23.13%) 0.714μs (±1.13%) -11.69%
benchExtract1Object 1000 5 1.855mb -21.23% 0.834μs (±0.72%) 0.830μs (±10.57%) -0.48%
benchHydrate1000Objects 3 5 1.855mb 0.00% 690.590μs (±30.19%) 682.843μs (±0.48%) -1.12%
benchExtract1000Objects 3 5 1.855mb 0.00% 410.479μs (±0.17%) 410.430μs (±0.78%) -0.01%

StackHydrator

subject revs its memory base current diff
benchHydrate1Object 1000 5 1.855mb 0.00% 1.266μs (±0.48%) 1.260μs (±1.84%) -0.50%
benchExtract1Object 1000 5 1.855mb 0.00% 1.690μs (±3.11%) 1.681μs (±1.02%) -0.53%
benchHydrate1000Objects 3 5 1.855mb 0.00% 1.242ms (±0.28%) 1.254ms (±0.41%) +1.02%
benchExtract1000Objects 3 5 1.855mb 0.00% 1.220ms (±1.76%) 1.248ms (±1.75%) +2.25%

StackHydratorWithLazy

subject revs its memory base current diff
benchHydrate1Object 1000 5 1.855mb 0.00% 0.307μs (±2.16%) 0.319μs (±2.99%) +4.12%
benchHydrate1ObjectTriggerInit 1000 5 1.855mb 0.00% 1.790μs (±2.14%) 1.764μs (±1.00%) -1.45%
benchHydrate1000Objects 3 5 1.855mb 0.00% 296.325μs (±1.69%) 296.408μs (±0.37%) +0.03%
benchHydrate1000ObjectsTriggerInit 3 5 1.855mb 0.00% 1.775ms (±1.14%) 1.776ms (±0.32%) +0.02%

StackHydratorWithCryptography

subject revs its memory base current diff
benchHydrate1Object 1000 5 1.855mb 0.00% 2.115μs (±2.37%) 2.183μs (±1.91%) +3.21%
benchExtract1Object 1000 5 1.855mb 0.00% 7.083μs (±0.54%) 7.115μs (±2.83%) +0.45%
benchHydrate1000Objects 3 5 1.855mb 0.00% 2.064ms (±1.35%) 2.101ms (±3.73%) +1.81%
benchExtract1000Objects 3 5 1.855mb 0.00% 6.669ms (±0.86%) 6.592ms (±0.89%) -1.16%

GeneratedHydratorWithCryptography

subject revs its memory base current diff
benchHydrate1Object 1000 5 1.855mb 0.00% 1.565μs (±3.70%) 1.509μs (±3.02%) -3.58%
benchExtract1Object 1000 5 1.855mb 0.00% 6.081μs (±0.81%) 6.042μs (±0.81%) -0.63%
benchHydrate1000Objects 3 5 1.855mb 0.00% 1.466ms (±2.18%) 1.433ms (±2.57%) -2.23%
benchExtract1000Objects 3 5 1.855mb 0.00% 5.570ms (±0.98%) 5.622ms (±0.67%) +0.93%

This comment gets updated every time a new commit comes in.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant