spatie / laravel-event-sourcing

The easiest way to get started with event sourcing in Laravel
https://docs.spatie.be/laravel-event-sourcing
MIT License
771 stars 165 forks source link

Could not persist aggregate because it seems to be changed by another process after it was retrieved in the current process. Current in-memory version is 1 #325

Closed JustSteveKing closed 2 years ago

JustSteveKing commented 2 years ago

My first time using v7 of the package, and had some issues with aggregates last night.

Could not persist aggregate PostAggregate (uuid: 19dad81e-b6ab-4012-a04a-401e1d35747d) because it seems to be changed by another process after it was retrieved in the current process. Current in-memory version is 1

Using Laravel 9.2.0 PHP 8.1

Aggregate:

class PostAggregate extends AggregateRoot
{
    protected static bool $allowConcurrency = false;

    public function createPost(DataObjectContract $object): self
    {
        $this->recordThat(
            domainEvent: new PostWasCreated(
                object: $object,
            ),
        );

        return $this;
    }

    public function getStoredEventRepository(): StoredEventRepository
    {
        return app(PostStoredEventsRepository::class);
    }
}

Event:

class PostWasCreated extends ShouldBeStored
{
    public function __construct(
        public readonly DataObjectContract $object,
    ) {}
}

Service Provider:

class EventSourcingServiceProvider extends ServiceProvider
{
    /**
     * @return void
     */
    public function register(): void
    {
        Projectionist::addProjector(
            projector: PostHandler::class,
        );

        Projectionist::addReactor(
            EmailModerators::class,
        );
    }
}

Projector:

class PostHandler extends Projector
{
    public function onPostWasCreated(PostWasCreated $event): void
    {
        /**
         * @var CreatePostContract
         */
        $action = resolve(CreatePostContract::class);

        $action->handle($event->object);
    }
}

The event is persisted in the database during testing with no errors, but when I interact with my code from the web UI - I get this issue. The aggregate is being triggered by a Livewire component that uses a service class to trigger the aggregate:

Livewire Component:

class CreateForm extends Component implements HasForms
{
    use InteractsWithForms;

    public bool $moderation = false;
    public null|string $title = null;
    public null|string $description = null;
    public null|string $content = null;
    public null|int $category = null;

    public function mount(): void
    {
        $this->form->fill();
    }

    public function submit(
        PostFactoryContract $factory,
        PostAggregateServiceContract $service,
    ) {
        $service->createPost(
            $factory->make(
                attributes: array_merge(
                    $this->form->getState(),
                    ['user_id' => auth()->id()],
                )
            )
        );

        return redirect()->route('dashboard');
    }

    protected function getFormSchema(): array
    {
        return [
            Grid::make(4)->schema([
                TextInput::make('title')->label('Post Title')->columnSpan(2)->required(),
                Select::make('category')->label('Post Category')->options(Category::get()->pluck('title', 'id'))->columnSpan(2)->required(),
                TextInput::make('description')->label('Post Description')->columnSpan(4)->required()->maxLength(120),
                MarkdownEditor::make('content')->label('Post Content')->columnSpan(4)->required(),
                    Toggle::make('moderation')->label('Submit for moderation')->columnSpan(4),
                ]),
        ];
    }

    public function render(): View
    {
        return view('livewire.posts.create-form');
    }
}

Service class:

class PostAggregateService implements PostAggregateServiceContract
{
    public function createPost(DataObjectContract $object): void
    {
        PostAggregate::retrieve(
            uuid: Str::uuid()->toString(),
        )->createPost(
            object: $object,
        )->persist();
    }
}

As you can see the service class tries to persist the aggregate, and this is where it fails. In my test code all I am doing is making sure that the event is stored:

it('triggers the aggregate root to store the event that a post was created', function () {
    $category = Category::factory()->create();
    auth()->loginUsingId(User::factory()->create()->id);

    expect(PostStoredEvent::query()->count())->toEqual(0);

    Livewire::test(CreateForm::class)
        ->set([
              'title' => 'pest php',
              'category' => $category->id,
              'description' => 'pest PHP is awesome, prove me wrong',
              'content' => 'Here be content, pirates and dragons',
          ])->call('submit')->assertHasNoErrors();

    expect(
        PostStoredEvent::query()->count()
    )->toEqual(1);
})->group('publishing');
JustSteveKing commented 2 years ago

Repo, if required: https://github.com/JustSteveKing/phponline.dev-new

brendt commented 2 years ago

Here's a whole discussion on concurrency: https://github.com/spatie/laravel-event-sourcing/discussions/214

There are two ways to solve your problem: use the command bus with the retry middleware (mentioned in that discussion), or manually refresh the ARs on places where these errors arise.

PS: please reopen this issue if the linked discussion doesn't solve the problem!

JustSteveKing commented 2 years ago

@brendt is this part in the v7 documentation at all? It sounds like it would solve the issue - but wouldn't mind reading your recommended approach šŸ˜„

brendt commented 2 years ago

I don't think it isā€¦ I added it as a kind of experimental feature, but maybe it's time to add it to the docs now.

Have you tried it out?

JustSteveKing commented 2 years ago

Not yet @brendt - if it is in the laravel event sourcing couese I could probably figure it out though

anabeto93 commented 2 years ago

@JustSteveKing I'm wondering if you've been able to follow the discussion on #214 and resolved it. I haven't been able to follow through and implement the suggestions but we have worked out a way to make concurrent persists work for us in the mean time. Solution seems hacky and thus haven't bothered to create a PR to this package to have it added.

Found ourselves in a situation where you need the aggregate to persist no matter what. When dealing with transactions (payments), after processing all validation rules and ensuring this is a valid request that needs to be processed by an external microservice, it is bad to throw an error such as CouldNotPersistAggregate. It is not really the fault of the merchant/requestor making the request to the gateway that another process has already persisted the aggregate to a higher version.

Find below the link to a sample repository created, (on a separate branch), to enable concurrent persists. I am really hoping it helps someone and or someone improves upon it in a way that would also help us very much.

https://github.com/anabeto93/spatie-es-auto-discovery/pull/new/feature/allow-concurrent-persists

Helper function here is allowConcurrentPersists() and the tests in AllowsConcurrentPersistsTest might help explain the challenge.

When stress testing the current implementation of the gateway with event-sourcing using locustio, the majority of the errors that occur apart from http 429 mostly has to do with CouldNotPersistAggregate. Still playing around with $tries value to settle between a higher value (slower response times) or lower value (faster response times but with more CouldNotPersistAggregate errors).

JustSteveKing commented 2 years ago

Hey @anabeto93 I haven't tried this recently if I am honest, however I am about to start another event sourcing project soon - so will be able to see where this is in terms of ability to achieve.

I had issues following the discussion if I am honest, as the docs did not have anything about actually using the command bus. I will dig into this and see what I can figure out