From 7c246a4a51280fe6890c4bbf8d2aa8a92d3a6a1e Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 00:35:02 -0400 Subject: [PATCH 01/40] wip --- src/Contracts/StoresEvents.php | 2 ++ src/Lifecycle/EventStore.php | 40 ++++++++++++++++++++++++++++ src/Lifecycle/StateReconstructor.php | 23 ++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 src/Lifecycle/StateReconstructor.php diff --git a/src/Contracts/StoresEvents.php b/src/Contracts/StoresEvents.php index aa145e18..945d6b9c 100644 --- a/src/Contracts/StoresEvents.php +++ b/src/Contracts/StoresEvents.php @@ -19,4 +19,6 @@ public function read( /** @param Event[] $events */ public function write(array $events): bool; + + public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): array; } diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index 5a24c5f1..f865fff3 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -8,6 +8,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\LazyCollection; +use InvalidArgumentException; use Ramsey\Uuid\UuidInterface; use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Contracts\StoresEvents; @@ -47,6 +48,45 @@ public function write(array $events): bool && VerbStateEvent::insert($this->formatRelationshipsForWrite($events)); } + public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): array + { + if ($state_id === null && $type === null) { + throw new InvalidArgumentException('You must specify a state ID or type.'); + } + + $known_state_ids = Collection::make([$state_id])->filter(); + $known_event_ids = new Collection(); + + do { + $discovered_event_ids = VerbStateEvent::query() + ->select('event_id') + ->distinct() + ->whereNotIn('event_id', $known_event_ids) + ->when( + value: $state_id === null, + callback: fn (Builder $query) => $query->where('state_type', $type), + default: fn (Builder $query) => $query->where('state_id', $state_id), + ) + ->toBase() + ->pluck('event_id'); + + $discovered_state_ids = VerbStateEvent::query() + ->select('state_id') + ->distinct() + ->whereIn('event_id', $known_event_ids) + ->whereNotIn('state_id', $known_state_ids) + ->toBase() + ->distinct() + ->pluck('state_id'); + + $known_event_ids = $known_event_ids->merge($discovered_event_ids); + $known_state_ids = $known_state_ids->merge($discovered_state_ids); + + } while ($discovered_state_ids->isNotEmpty()); + + return [$known_state_ids, $known_event_ids]; + } + protected function readEvents( ?State $state, Bits|UuidInterface|AbstractUid|int|string|null $after_id, diff --git a/src/Lifecycle/StateReconstructor.php b/src/Lifecycle/StateReconstructor.php new file mode 100644 index 00000000..933dd53d --- /dev/null +++ b/src/Lifecycle/StateReconstructor.php @@ -0,0 +1,23 @@ +events->allRelatedIds($id, $type); + + // TODO + } +} From 79e69d495e5e80cf128033a8c18e5ccf7432c298 Mon Sep 17 00:00:00 2001 From: inxilpro Date: Wed, 21 Aug 2024 04:35:25 +0000 Subject: [PATCH 02/40] Fix styling --- src/Lifecycle/EventStore.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index f865fff3..5b59deb3 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -55,7 +55,7 @@ public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $st } $known_state_ids = Collection::make([$state_id])->filter(); - $known_event_ids = new Collection(); + $known_event_ids = new Collection; do { $discovered_event_ids = VerbStateEvent::query() From 5c43a99833e44ce3e750b2c39b516bbf1859fb41 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 12:57:05 -0400 Subject: [PATCH 03/40] wip --- src/Contracts/StoresEvents.php | 5 +- src/Lifecycle/EventStore.php | 23 ++++-- src/Lifecycle/StateManager.php | 7 +- src/Lifecycle/StateReconstructor.php | 10 +-- src/State.php | 6 +- src/Support/StateInstanceCache.php | 9 ++- src/Testing/EventStoreFake.php | 10 +++ tests/Unit/StateReconstitutionTest.php | 103 +++++++++++++++++++++++++ 8 files changed, 149 insertions(+), 24 deletions(-) create mode 100644 tests/Unit/StateReconstitutionTest.php diff --git a/src/Contracts/StoresEvents.php b/src/Contracts/StoresEvents.php index 945d6b9c..b848c81e 100644 --- a/src/Contracts/StoresEvents.php +++ b/src/Contracts/StoresEvents.php @@ -3,6 +3,7 @@ namespace Thunk\Verbs\Contracts; use Glhd\Bits\Bits; +use Illuminate\Support\Collection; use Illuminate\Support\LazyCollection; use Ramsey\Uuid\UuidInterface; use Symfony\Component\Uid\AbstractUid; @@ -17,8 +18,10 @@ public function read( bool $singleton = false ): LazyCollection; + public function get(iterable $ids): LazyCollection; + /** @param Event[] $events */ public function write(array $events): bool; - public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): array; + public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): Collection; } diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index f865fff3..66d4cd75 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -36,6 +36,15 @@ public function read( ->map(fn (VerbEvent $model) => $model->event()); } + public function get(iterable $ids): LazyCollection + { + return VerbEvent::query() + ->whereIn('id', collect($ids)) + ->lazyById() + ->each(fn (VerbEvent $model) => $this->metadata->set($model->event(), $model->metadata())) + ->map(fn (VerbEvent $model) => $model->event()); + } + public function write(array $events): bool { if (empty($events)) { @@ -48,7 +57,7 @@ public function write(array $events): bool && VerbStateEvent::insert($this->formatRelationshipsForWrite($events)); } - public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): array + public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): Collection { if ($state_id === null && $type === null) { throw new InvalidArgumentException('You must specify a state ID or type.'); @@ -62,14 +71,13 @@ public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $st ->select('event_id') ->distinct() ->whereNotIn('event_id', $known_event_ids) - ->when( - value: $state_id === null, - callback: fn (Builder $query) => $query->where('state_type', $type), - default: fn (Builder $query) => $query->where('state_id', $state_id), - ) + ->unless($type === null, fn (Builder $query) => $query->where('state_type', $type)) + ->unless($state_id === null, fn (Builder $query) => $query->where('state_id', $state_id)) ->toBase() ->pluck('event_id'); + $known_event_ids = $known_event_ids->merge($discovered_event_ids); + $discovered_state_ids = VerbStateEvent::query() ->select('state_id') ->distinct() @@ -79,12 +87,11 @@ public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $st ->distinct() ->pluck('state_id'); - $known_event_ids = $known_event_ids->merge($discovered_event_ids); $known_state_ids = $known_state_ids->merge($discovered_state_ids); } while ($discovered_state_ids->isNotEmpty()); - return [$known_state_ids, $known_event_ids]; + return $known_event_ids; } protected function readEvents( diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index d36d85eb..c69249a3 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -4,6 +4,7 @@ use Glhd\Bits\Bits; use Ramsey\Uuid\UuidInterface; +use ReflectionClass; use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Contracts\StoresEvents; use Thunk\Verbs\Contracts\StoresSnapshots; @@ -51,8 +52,12 @@ public function load(Bits|UuidInterface|AbstractUid|int|string $id, string $type throw new UnexpectedValueException(sprintf('Expected State <%d> to be of type "%s" but got "%s"', $id, class_basename($type), class_basename($state))); } } else { - $state = $type::make(); + // State::__construct() auto-registers the state with the StateManager, so we need to + // skip the constructor until we've already set the ID. + $reflect = new ReflectionClass($type); + $state = $reflect->newInstanceWithoutConstructor(); $state->id = $id; + $state->__construct(); } $this->remember($state); diff --git a/src/Lifecycle/StateReconstructor.php b/src/Lifecycle/StateReconstructor.php index 933dd53d..171107a1 100644 --- a/src/Lifecycle/StateReconstructor.php +++ b/src/Lifecycle/StateReconstructor.php @@ -6,18 +6,18 @@ use Ramsey\Uuid\UuidInterface; use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Contracts\StoresEvents; -use Thunk\Verbs\Support\StateInstanceCache; +use Thunk\Verbs\Event; class StateReconstructor { public function __construct( protected StoresEvents $events, + protected Dispatcher $dispatcher, ) {} - public function reconstruct(string $type, Bits|UuidInterface|AbstractUid|int|string|null $id): StateInstanceCache + public function reconstruct(string $type, Bits|UuidInterface|AbstractUid|int|string|null $id) { - [$state_ids, $event_ids] = $this->events->allRelatedIds($id, $type); - - // TODO + $this->events->get($this->events->allRelatedIds($id, $type)) + ->each(fn (Event $event) => $this->dispatcher->apply($event)); } } diff --git a/src/State.php b/src/State.php index 0ddfac5c..f06f5b8c 100644 --- a/src/State.php +++ b/src/State.php @@ -30,11 +30,7 @@ public static function make(...$args): static $args = $args[0]; } - $state = app(Serializer::class)->deserialize(static::class, $args, call_constructor: true); - - app(StateManager::class)->register($state); - - return $state; + return app(Serializer::class)->deserialize(static::class, $args, call_constructor: true); } /** @return StateFactory */ diff --git a/src/Support/StateInstanceCache.php b/src/Support/StateInstanceCache.php index 4f233b5e..e91e1559 100644 --- a/src/Support/StateInstanceCache.php +++ b/src/Support/StateInstanceCache.php @@ -52,10 +52,6 @@ public function has(string|int $key): bool public function forget(string|int $key): static { - if ($this->discard_callback) { - call_user_func($this->discard_callback, $this->cache[$key]); - } - unset($this->cache[$key]); return $this; @@ -80,6 +76,11 @@ public function reset(): static return $this; } + public function all(): array + { + return $this->cache; + } + public function onDiscard(Closure $callback): static { $this->discard_callback = $callback; diff --git a/src/Testing/EventStoreFake.php b/src/Testing/EventStoreFake.php index 508ac711..5ea640b8 100644 --- a/src/Testing/EventStoreFake.php +++ b/src/Testing/EventStoreFake.php @@ -57,6 +57,16 @@ public function write(array $events): bool return true; } + public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): Collection + { + // FIXME + } + + public function get(iterable $ids): LazyCollection + { + // FIXME + } + /** @return Collection */ public function committed(string $class_name, ?Closure $filter = null): Collection { diff --git a/tests/Unit/StateReconstitutionTest.php b/tests/Unit/StateReconstitutionTest.php new file mode 100644 index 00000000..4409cccd --- /dev/null +++ b/tests/Unit/StateReconstitutionTest.php @@ -0,0 +1,103 @@ +instance(StoresSnapshots::class, new SnapshotStoreFake); + app()->instance(StoresEvents::class, new EventStoreFake(app(MetadataManager::class))); +}); + +/* + * The Problem(s) + * + * FIRST PROBLEM: + * - We try to load state1, but we don't have an up-to-date snapshot + * - StateManager::load tries to reconstitute state from events + * - One of those Event::apply methods load state2 + * - Best case scenario: we reconstitute state2 before continuing + * - Worst case scenario: reconstituting state2 tries to reconstitute state1, and we're in an infinite loop + * - (if no loop) state1 continues to reconstitute, but it's acting with state2 FULLY up-to-date, not + * just up-to-date with where state1 happens to be + * + * TO TEST FIRST PROBLEM: + * - Event1 adds State1::counter to State2::counter and increments State2::counter + * - Event2 increments State2::counter + * + * SECOND PROBLEM: + * - We try to load state1, but we don't have an up-to-date snapshot + * - StateManager::load tries to reconstitute state from events + * - One of those Event::apply methods requires state1 and state2, so we need to load state2 + * - Reconstituting state2 re-runs the same apply method on state2 before also running it on state1 + * - Double-apply happens + */ + +test('scenario 1', function () { + $state1_id = snowflake_id(); + $state2_id = snowflake_id(); + + StateReconstitutionTestEvent1::fire(state1_id: $state1_id, state2_id: $state2_id); + + $state1 = StateReconstitutionTestState1::load($state1_id); + $state2 = StateReconstitutionTestState2::load($state2_id); + + expect($state1->counter)->toBe(0) + ->and($state2->counter)->toBe(1); + + StateReconstitutionTestEvent2::fire(state2_id: $state2_id); + + expect($state1->counter)->toBe(0) + ->and($state2->counter)->toBe(2); + + Verbs::commit(); + app(StateManager::class)->reset(include_storage: true); + + $state1 = StateReconstitutionTestState1::load($state1_id); + $state2 = StateReconstitutionTestState2::load($state2_id); + + expect($state1->counter)->toBe(0) + ->and($state2->counter)->toBe(2); +}); + +class StateReconstitutionTestState1 extends State +{ + public int $counter = 0; +} + +class StateReconstitutionTestState2 extends State +{ + public int $counter = 0; +} + +class StateReconstitutionTestEvent1 extends \Thunk\Verbs\Event +{ + #[StateId(StateReconstitutionTestState1::class)] + public int $state1_id; + + #[StateId(StateReconstitutionTestState2::class)] + public int $state2_id; + + public function apply(StateReconstitutionTestState1 $state1, StateReconstitutionTestState2 $state2): void + { + $state1->counter = $state1->counter + $state2->counter; + $state2->counter++; + } +} + +class StateReconstitutionTestEvent2 extends \Thunk\Verbs\Event +{ + #[StateId(StateReconstitutionTestState2::class)] + public int $state2_id; + + public function apply(StateReconstitutionTestState2 $state2): void + { + $state2->counter++; + } +} From 93582b521543c9781fb1da4ac73042f5159f5991 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 13:58:11 -0400 Subject: [PATCH 04/40] Allow EventStateRegistry to reset --- src/Event.php | 7 +------ src/Lifecycle/StateManager.php | 4 ++++ src/Support/EventStateRegistry.php | 19 +++++++++++++++++-- src/VerbsServiceProvider.php | 1 + 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/Event.php b/src/Event.php index 23951c41..5f69d8b6 100644 --- a/src/Event.php +++ b/src/Event.php @@ -13,7 +13,6 @@ use Thunk\Verbs\Support\EventStateRegistry; use Thunk\Verbs\Support\PendingEvent; use Thunk\Verbs\Support\StateCollection; -use WeakMap; /** * @method static static fire(...$args) @@ -42,11 +41,7 @@ public function metadata(?string $key = null, mixed $default = null): mixed public function states(): StateCollection { - // TODO: This is a bit hacky, but is probably OK right now - - static $map = new WeakMap; - - return $map[$this] ??= app(EventStateRegistry::class)->getStates($this); + return app(EventStateRegistry::class)->getStates($this); } /** diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index c69249a3..971c636d 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -12,6 +12,7 @@ use Thunk\Verbs\Exceptions\StateCacheSizeTooLow; use Thunk\Verbs\Facades\Id; use Thunk\Verbs\State; +use Thunk\Verbs\Support\EventStateRegistry; use Thunk\Verbs\Support\StateInstanceCache; use UnexpectedValueException; @@ -24,6 +25,7 @@ public function __construct( protected StoresSnapshots $snapshots, protected StoresEvents $events, protected StateInstanceCache $states, + protected EventStateRegistry $event_states, ) { $this->states->onDiscard(fn () => throw_unless($this->is_replaying, StateCacheSizeTooLow::class)); } @@ -102,6 +104,8 @@ public function setReplaying(bool $replaying): static public function reset(bool $include_storage = false): static { $this->states->reset(); + $this->event_states->reset(); + $this->is_replaying = false; if ($include_storage) { diff --git a/src/Support/EventStateRegistry.php b/src/Support/EventStateRegistry.php index 3bbeefea..969114cd 100644 --- a/src/Support/EventStateRegistry.php +++ b/src/Support/EventStateRegistry.php @@ -12,16 +12,31 @@ use Thunk\Verbs\Event; use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; +use WeakMap; class EventStateRegistry { protected array $discovered_attributes = []; + protected WeakMap $discovered_states; + public function __construct( protected StateManager $manager - ) {} + ) { + $this->discovered_states = new WeakMap(); + } + + public function reset() + { + $this->discovered_states = new WeakMap(); + } public function getStates(Event $event): StateCollection + { + return $this->discovered_states[$event] ??= $this->discoverStates($event); + } + + protected function discoverStates(Event $event): StateCollection { $discovered = new StateCollection; $deferred = new StateCollection; @@ -40,7 +55,7 @@ public function getStates(Event $event): StateCollection // Once we've loaded everything else, try to discover any deferred attributes $deferred->each(fn (StateDiscoveryAttribute $attr) => $this->discoverAndPushState($attr, $event, $discovered)); - return $discovered; + return $this->discovered_states[$event] = $discovered; } /** @return Collection */ diff --git a/src/VerbsServiceProvider.php b/src/VerbsServiceProvider.php index 61d0869b..a8856139 100644 --- a/src/VerbsServiceProvider.php +++ b/src/VerbsServiceProvider.php @@ -75,6 +75,7 @@ public function packageRegistered() states: new StateInstanceCache( capacity: $app->make(Repository::class)->get('verbs.state_cache_size', 100) ), + event_states: $app->make(EventStateRegistry::class), ); }); From ec6082f7714b2003708c513a049a81a286cf9ffa Mon Sep 17 00:00:00 2001 From: inxilpro Date: Wed, 21 Aug 2024 17:58:35 +0000 Subject: [PATCH 05/40] Fix styling --- src/Support/EventStateRegistry.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Support/EventStateRegistry.php b/src/Support/EventStateRegistry.php index 969114cd..20e411f7 100644 --- a/src/Support/EventStateRegistry.php +++ b/src/Support/EventStateRegistry.php @@ -23,12 +23,12 @@ class EventStateRegistry public function __construct( protected StateManager $manager ) { - $this->discovered_states = new WeakMap(); + $this->discovered_states = new WeakMap; } public function reset() { - $this->discovered_states = new WeakMap(); + $this->discovered_states = new WeakMap; } public function getStates(Event $event): StateCollection From 25dbc6cc163990c29b133b3ac8c68e40317b99cb Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 15:41:14 -0400 Subject: [PATCH 06/40] Undo infinite loop --- src/Lifecycle/StateManager.php | 3 +-- src/VerbsServiceProvider.php | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 971c636d..39598bb4 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -25,7 +25,6 @@ public function __construct( protected StoresSnapshots $snapshots, protected StoresEvents $events, protected StateInstanceCache $states, - protected EventStateRegistry $event_states, ) { $this->states->onDiscard(fn () => throw_unless($this->is_replaying, StateCacheSizeTooLow::class)); } @@ -104,7 +103,7 @@ public function setReplaying(bool $replaying): static public function reset(bool $include_storage = false): static { $this->states->reset(); - $this->event_states->reset(); + app(EventStateRegistry::class)->reset(); $this->is_replaying = false; diff --git a/src/VerbsServiceProvider.php b/src/VerbsServiceProvider.php index a8856139..61d0869b 100644 --- a/src/VerbsServiceProvider.php +++ b/src/VerbsServiceProvider.php @@ -75,7 +75,6 @@ public function packageRegistered() states: new StateInstanceCache( capacity: $app->make(Repository::class)->get('verbs.state_cache_size', 100) ), - event_states: $app->make(EventStateRegistry::class), ); }); From 1989b9cdceaf73a33eeb10ac0bb2948ee6588654 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 16:26:35 -0400 Subject: [PATCH 07/40] GREEN Co-Authored-By: Skyler Katz --- src/Lifecycle/EventStore.php | 33 +++++++++++++++----------- src/Lifecycle/StateManager.php | 24 ++++++++++--------- tests/Unit/StateReconstitutionTest.php | 14 ++++------- 3 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index 4090dcd1..c9709a23 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -64,23 +64,18 @@ public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $st } $known_state_ids = Collection::make([$state_id])->filter(); - $known_event_ids = new Collection; + $known_event_ids = VerbStateEvent::query() + ->distinct() + ->select('event_id') + ->unless($type === null, fn (Builder $query) => $query->where('state_type', $type)) + ->unless($state_id === null, fn (Builder $query) => $query->where('state_id', $state_id)) + ->toBase() + ->pluck('event_id'); do { - $discovered_event_ids = VerbStateEvent::query() - ->select('event_id') - ->distinct() - ->whereNotIn('event_id', $known_event_ids) - ->unless($type === null, fn (Builder $query) => $query->where('state_type', $type)) - ->unless($state_id === null, fn (Builder $query) => $query->where('state_id', $state_id)) - ->toBase() - ->pluck('event_id'); - - $known_event_ids = $known_event_ids->merge($discovered_event_ids); - $discovered_state_ids = VerbStateEvent::query() - ->select('state_id') ->distinct() + ->select('state_id') ->whereIn('event_id', $known_event_ids) ->whereNotIn('state_id', $known_state_ids) ->toBase() @@ -89,7 +84,17 @@ public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $st $known_state_ids = $known_state_ids->merge($discovered_state_ids); - } while ($discovered_state_ids->isNotEmpty()); + $discovered_event_ids = VerbStateEvent::query() + ->distinct() + ->select('event_id') + ->whereNotIn('event_id', $known_event_ids) + ->whereIn('state_id', $known_state_ids) + ->toBase() + ->pluck('event_id'); + + $known_event_ids = $known_event_ids->merge($discovered_event_ids); + + } while ($discovered_event_ids->isNotEmpty()); return $known_event_ids; } diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 39598bb4..3d25e629 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -18,6 +18,8 @@ class StateManager { + protected bool $is_reconstituting = false; + protected bool $is_replaying = false; public function __construct( @@ -62,7 +64,12 @@ public function load(Bits|UuidInterface|AbstractUid|int|string $id, string $type } $this->remember($state); - $this->reconstitute($state); + + if (! $this->is_reconstituting) { + $this->is_reconstituting = true; + $this->reconstitute($state); + $this->is_reconstituting = false; + } return $state; } @@ -126,16 +133,11 @@ protected function reconstitute(State $state, bool $singleton = false): static // When we're replaying, the Broker is in charge of applying the correct events // to the State, so we only need to do it *outside* of replays. if (! $this->is_replaying) { - $this->events - ->read(state: $state, after_id: $state->last_event_id, singleton: $singleton) - ->each(fn (Event $event) => $this->dispatcher->apply($event)); - - // It's possible for an event to mutate state out of order when reconstituting, - // so as a precaution, we'll clear all other states from the store and reload - // them from snapshots as needed in the rest of the request. - // FIXME: We still need to figure this out - // $this->states->reset(); - //$this->remember($state); + // $this->events + // ->read(state: $state, after_id: $state->last_event_id, singleton: $singleton) + // ->each(fn (Event $event) => $this->dispatcher->apply($event)); + (new StateReconstructor($this->events, $this->dispatcher)) + ->reconstruct($state::class, $singleton ? null : $state->id); } return $this; diff --git a/tests/Unit/StateReconstitutionTest.php b/tests/Unit/StateReconstitutionTest.php index 4409cccd..a4dd5f9e 100644 --- a/tests/Unit/StateReconstitutionTest.php +++ b/tests/Unit/StateReconstitutionTest.php @@ -1,19 +1,9 @@ instance(StoresSnapshots::class, new SnapshotStoreFake); - app()->instance(StoresEvents::class, new EventStoreFake(app(MetadataManager::class))); -}); /* * The Problem(s) @@ -39,6 +29,8 @@ * - Double-apply happens */ +// FIXME: We need to account for partially up-to-date snapshots that only need *some* events applied but not all + test('scenario 1', function () { $state1_id = snowflake_id(); $state2_id = snowflake_id(); @@ -86,6 +78,7 @@ class StateReconstitutionTestEvent1 extends \Thunk\Verbs\Event public function apply(StateReconstitutionTestState1 $state1, StateReconstitutionTestState2 $state2): void { + dump("Applying event {$this->id}"); $state1->counter = $state1->counter + $state2->counter; $state2->counter++; } @@ -98,6 +91,7 @@ class StateReconstitutionTestEvent2 extends \Thunk\Verbs\Event public function apply(StateReconstitutionTestState2 $state2): void { + dump("Applying event {$this->id}"); $state2->counter++; } } From 712463f046e73bcb30c597b5a555ceff2d9b1787 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 16:44:44 -0400 Subject: [PATCH 08/40] refactoring Co-Authored-By: Skyler Katz --- src/Contracts/StoresEvents.php | 2 +- src/Lifecycle/EventStore.php | 13 ++++--------- src/Lifecycle/StateManager.php | 7 ++----- src/Lifecycle/StateReconstructor.php | 23 ----------------------- src/Testing/EventStoreFake.php | 2 +- 5 files changed, 8 insertions(+), 39 deletions(-) delete mode 100644 src/Lifecycle/StateReconstructor.php diff --git a/src/Contracts/StoresEvents.php b/src/Contracts/StoresEvents.php index b848c81e..2e80acf7 100644 --- a/src/Contracts/StoresEvents.php +++ b/src/Contracts/StoresEvents.php @@ -23,5 +23,5 @@ public function get(iterable $ids): LazyCollection; /** @param Event[] $events */ public function write(array $events): bool; - public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): Collection; + public function allRelatedIds(State $state, bool $singleton = false): Collection; } diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index c9709a23..f88eea7f 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -8,7 +8,6 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\LazyCollection; -use InvalidArgumentException; use Ramsey\Uuid\UuidInterface; use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Contracts\StoresEvents; @@ -57,18 +56,14 @@ public function write(array $events): bool && VerbStateEvent::insert($this->formatRelationshipsForWrite($events)); } - public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): Collection + public function allRelatedIds(State $state, bool $singleton = false): Collection { - if ($state_id === null && $type === null) { - throw new InvalidArgumentException('You must specify a state ID or type.'); - } - - $known_state_ids = Collection::make([$state_id])->filter(); + $known_state_ids = $singleton ? new Collection() : Collection::make([$state->id]); $known_event_ids = VerbStateEvent::query() ->distinct() ->select('event_id') - ->unless($type === null, fn (Builder $query) => $query->where('state_type', $type)) - ->unless($state_id === null, fn (Builder $query) => $query->where('state_id', $state_id)) + ->where('state_type', $state::class) + ->unless($singleton, fn (Builder $query) => $query->where('state_id', $state->id)) ->toBase() ->pluck('event_id'); diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 3d25e629..9e4a110a 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -133,11 +133,8 @@ protected function reconstitute(State $state, bool $singleton = false): static // When we're replaying, the Broker is in charge of applying the correct events // to the State, so we only need to do it *outside* of replays. if (! $this->is_replaying) { - // $this->events - // ->read(state: $state, after_id: $state->last_event_id, singleton: $singleton) - // ->each(fn (Event $event) => $this->dispatcher->apply($event)); - (new StateReconstructor($this->events, $this->dispatcher)) - ->reconstruct($state::class, $singleton ? null : $state->id); + $this->events->get($this->events->allRelatedIds($state, $singleton)) + ->each($this->dispatcher->apply(...)); } return $this; diff --git a/src/Lifecycle/StateReconstructor.php b/src/Lifecycle/StateReconstructor.php deleted file mode 100644 index 171107a1..00000000 --- a/src/Lifecycle/StateReconstructor.php +++ /dev/null @@ -1,23 +0,0 @@ -events->get($this->events->allRelatedIds($id, $type)) - ->each(fn (Event $event) => $this->dispatcher->apply($event)); - } -} diff --git a/src/Testing/EventStoreFake.php b/src/Testing/EventStoreFake.php index 5ea640b8..f024d80c 100644 --- a/src/Testing/EventStoreFake.php +++ b/src/Testing/EventStoreFake.php @@ -57,7 +57,7 @@ public function write(array $events): bool return true; } - public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): Collection + public function allRelatedIds(State $state, bool $singleton = false): Collection { // FIXME } From 72188a555d956422aec979b5a888706947678582 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 16:50:44 -0400 Subject: [PATCH 09/40] One more failing test Co-Authored-By: Skyler Katz --- tests/Unit/StateReconstitutionTest.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/Unit/StateReconstitutionTest.php b/tests/Unit/StateReconstitutionTest.php index a4dd5f9e..4fc3bdad 100644 --- a/tests/Unit/StateReconstitutionTest.php +++ b/tests/Unit/StateReconstitutionTest.php @@ -3,6 +3,7 @@ use Thunk\Verbs\Attributes\Autodiscovery\StateId; use Thunk\Verbs\Facades\Verbs; use Thunk\Verbs\Lifecycle\StateManager; +use Thunk\Verbs\Models\VerbSnapshot; use Thunk\Verbs\State; /* @@ -58,6 +59,26 @@ ->and($state2->counter)->toBe(2); }); +test('partially up-to-date snapshots', function () { + StateReconstitutionTestEvent2::fire(state2_id: 1); + $event2 = StateReconstitutionTestEvent2::fire(state2_id: 1); + StateReconstitutionTestEvent2::fire(state2_id: 1); + + Verbs::commit(); + + $snapshot = VerbSnapshot::query()->where('state_id', 1)->sole(); + $snapshot->update([ + 'data' => '{"counter":2}', + 'last_event_id' => $event2->id, + ]); + + app(StateManager::class)->reset(); + + $state = StateReconstitutionTestState2::load(1); + + expect($state->counter)->toBe(3); +}); + class StateReconstitutionTestState1 extends State { public int $counter = 0; From 129b0316981344bdcd6fa5f43bb4cc741c7b73aa Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 16:58:16 -0400 Subject: [PATCH 10/40] Make test moar bad Co-Authored-By: Skyler Katz --- tests/Unit/StateReconstitutionTest.php | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/tests/Unit/StateReconstitutionTest.php b/tests/Unit/StateReconstitutionTest.php index 4fc3bdad..c1885266 100644 --- a/tests/Unit/StateReconstitutionTest.php +++ b/tests/Unit/StateReconstitutionTest.php @@ -60,23 +60,33 @@ }); test('partially up-to-date snapshots', function () { - StateReconstitutionTestEvent2::fire(state2_id: 1); - $event2 = StateReconstitutionTestEvent2::fire(state2_id: 1); - StateReconstitutionTestEvent2::fire(state2_id: 1); + StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=1 + $event2 = StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=2 + $event3 = StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=2, 2=3 + StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=2, 2=4 + StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=6, 2=5 Verbs::commit(); - $snapshot = VerbSnapshot::query()->where('state_id', 1)->sole(); - $snapshot->update([ + $snapshot1 = VerbSnapshot::query()->where('state_id', 1)->sole(); + $snapshot1->update([ + 'data' => '{"counter":2}', + 'last_event_id' => $event3->id, + ]); + + $snapshot2 = VerbSnapshot::query()->where('state_id', 2)->sole(); + $snapshot2->update([ 'data' => '{"counter":2}', 'last_event_id' => $event2->id, ]); app(StateManager::class)->reset(); - $state = StateReconstitutionTestState2::load(1); + $state1 = StateReconstitutionTestState1::load(1); + $state2 = StateReconstitutionTestState2::load(2); - expect($state->counter)->toBe(3); + expect($state1->counter)->toBe(6); + expect($state2->counter)->toBe(5); }); class StateReconstitutionTestState1 extends State From 064fb3c326a2733e0ed5392c3d3981cb9afc94b2 Mon Sep 17 00:00:00 2001 From: inxilpro Date: Wed, 21 Aug 2024 21:00:46 +0000 Subject: [PATCH 11/40] Fix styling --- src/Lifecycle/EventStore.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index f88eea7f..7576428b 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -58,7 +58,7 @@ public function write(array $events): bool public function allRelatedIds(State $state, bool $singleton = false): Collection { - $known_state_ids = $singleton ? new Collection() : Collection::make([$state->id]); + $known_state_ids = $singleton ? new Collection : Collection::make([$state->id]); $known_event_ids = VerbStateEvent::query() ->distinct() ->select('event_id') From f87eb5637afe8079dfa16e6dab332e619138b58f Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 17:03:10 -0400 Subject: [PATCH 12/40] wip Co-Authored-By: Skyler Katz --- src/Lifecycle/StateManager.php | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 9e4a110a..825e0b67 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -64,12 +64,7 @@ public function load(Bits|UuidInterface|AbstractUid|int|string $id, string $type } $this->remember($state); - - if (! $this->is_reconstituting) { - $this->is_reconstituting = true; - $this->reconstitute($state); - $this->is_reconstituting = false; - } + $this->reconstitute($state); return $state; } @@ -131,10 +126,18 @@ public function prune(): static protected function reconstitute(State $state, bool $singleton = false): static { // When we're replaying, the Broker is in charge of applying the correct events - // to the State, so we only need to do it *outside* of replays. - if (! $this->is_replaying) { - $this->events->get($this->events->allRelatedIds($state, $singleton)) - ->each($this->dispatcher->apply(...)); + // to the State, so we need to skip during replays. Similarly, if we're already + // reconstituting in a recursive call, the root call is responsible for applying + // events, so we should also skip in that case. + + if (! $this->is_replaying && ! $this->is_reconstituting) { + try { + $this->is_reconstituting = true; + $this->events->get($this->events->allRelatedIds($state, $singleton)) + ->each($this->dispatcher->apply(...)); + } finally { + $this->is_reconstituting = false; + } } return $this; From 4edd58528bdd23489f8e4f4ea6db6a92343316f4 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 17:53:19 -0400 Subject: [PATCH 13/40] wip --- src/Lifecycle/EventStore.php | 2 +- src/Lifecycle/StateManager.php | 22 +++++++++++ tests/Unit/StateReconstitutionTest.php | 53 ++++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index 7576428b..4b763184 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -91,7 +91,7 @@ public function allRelatedIds(State $state, bool $singleton = false): Collection } while ($discovered_event_ids->isNotEmpty()); - return $known_event_ids; + return $known_event_ids->sort(); } protected function readEvents( diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 825e0b67..2588f05f 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -5,6 +5,7 @@ use Glhd\Bits\Bits; use Ramsey\Uuid\UuidInterface; use ReflectionClass; +use RuntimeException; use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Contracts\StoresEvents; use Thunk\Verbs\Contracts\StoresSnapshots; @@ -134,6 +135,27 @@ protected function reconstitute(State $state, bool $singleton = false): static try { $this->is_reconstituting = true; $this->events->get($this->events->allRelatedIds($state, $singleton)) + ->filter(function (Event $event) { + $last_event_ids = $event->states() + ->map(fn (State $state) => $state->last_event_id) + ->filter(); + + $min = $last_event_ids->min() ?? PHP_INT_MIN; + $max = $last_event_ids->max() ?? PHP_INT_MIN; + + // If all states have had this or future events applied, just ignore them + if ($min >= $event->id && $max >= $event->id) { + return false; + } + + // We should never be in a situation where some events are ahead and + // others are behind, so if that's the case we'll throw an exception + if ($max > $event->id && $min <= $event->id) { + throw new RuntimeException('Trying to apply an event to states that are out of sync.'); + } + + return true; + }) ->each($this->dispatcher->apply(...)); } finally { $this->is_reconstituting = false; diff --git a/tests/Unit/StateReconstitutionTest.php b/tests/Unit/StateReconstitutionTest.php index c1885266..a1bba15d 100644 --- a/tests/Unit/StateReconstitutionTest.php +++ b/tests/Unit/StateReconstitutionTest.php @@ -61,13 +61,19 @@ test('partially up-to-date snapshots', function () { StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=1 - $event2 = StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=2 + StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=2 $event3 = StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=2, 2=3 StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=2, 2=4 StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=6, 2=5 Verbs::commit(); + $state1 = StateReconstitutionTestState1::load(1); + $state2 = StateReconstitutionTestState2::load(2); + + expect($state1->counter)->toBe(6) + ->and($state2->counter)->toBe(5); + $snapshot1 = VerbSnapshot::query()->where('state_id', 1)->sole(); $snapshot1->update([ 'data' => '{"counter":2}', @@ -76,18 +82,56 @@ $snapshot2 = VerbSnapshot::query()->where('state_id', 2)->sole(); $snapshot2->update([ + 'data' => '{"counter":3}', + 'last_event_id' => $event3->id, + ]); + + app(StateManager::class)->reset(); + + $state1 = StateReconstitutionTestState1::load(1); + $state2 = StateReconstitutionTestState2::load(2); + + expect($state1->counter)->toBe(6); + expect($state2->counter)->toBe(5); +}); + +test('partially up-to-date, but out of sync snapshots', function () { + StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=1 + $event2 = StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=2 + $event3 = StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=2, 2=3 + StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=2, 2=4 + StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=6, 2=5 + + Verbs::commit(); + + $state1 = StateReconstitutionTestState1::load(1); + $state2 = StateReconstitutionTestState2::load(2); + + expect($state1->counter)->toBe(6) + ->and($state2->counter)->toBe(5); + + $snapshot1 = VerbSnapshot::query()->where('state_id', 1)->sole(); + $snapshot1->update([ 'data' => '{"counter":2}', + 'last_event_id' => $event3->id, + ]); + + $snapshot2 = VerbSnapshot::query()->where('state_id', 2)->sole(); + $snapshot2->update([ + 'data' => '{"counter":2}', // FIXME: This maybe can't happen? 'last_event_id' => $event2->id, ]); app(StateManager::class)->reset(); + // dump('---- RESET ----'); + $state1 = StateReconstitutionTestState1::load(1); $state2 = StateReconstitutionTestState2::load(2); expect($state1->counter)->toBe(6); expect($state2->counter)->toBe(5); -}); +})->skip('This may actually not be possible'); class StateReconstitutionTestState1 extends State { @@ -109,8 +153,9 @@ class StateReconstitutionTestEvent1 extends \Thunk\Verbs\Event public function apply(StateReconstitutionTestState1 $state1, StateReconstitutionTestState2 $state2): void { - dump("Applying event {$this->id}"); + // dump("[event 1] incrementing \$state1->counter from {$state1->counter} to ({$state1->counter} + {$state2->counter})"); $state1->counter = $state1->counter + $state2->counter; + // dump("[event 1] incrementing \$state2->counter from {$state2->counter} to \$state2->counter++"); $state2->counter++; } } @@ -122,7 +167,7 @@ class StateReconstitutionTestEvent2 extends \Thunk\Verbs\Event public function apply(StateReconstitutionTestState2 $state2): void { - dump("Applying event {$this->id}"); + // dump("[event 2] incrementing \$state2->counter from {$state2->counter} to \$state2->counter++"); $state2->counter++; } } From 9609c9f02f4d4fd89905e0b5b66428d23528a68d Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Thu, 22 Aug 2024 16:31:10 -0400 Subject: [PATCH 14/40] wip --- src/Contracts/StoresEvents.php | 4 +-- src/Lifecycle/AggregateStateSummary.php | 17 ++++++++++++ src/Lifecycle/EventStore.php | 37 ++++++++++++++++++++----- src/Lifecycle/StateManager.php | 6 +++- src/Testing/EventStoreFake.php | 3 +- 5 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 src/Lifecycle/AggregateStateSummary.php diff --git a/src/Contracts/StoresEvents.php b/src/Contracts/StoresEvents.php index 2e80acf7..e1dc2e9b 100644 --- a/src/Contracts/StoresEvents.php +++ b/src/Contracts/StoresEvents.php @@ -3,11 +3,11 @@ namespace Thunk\Verbs\Contracts; use Glhd\Bits\Bits; -use Illuminate\Support\Collection; use Illuminate\Support\LazyCollection; use Ramsey\Uuid\UuidInterface; use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Event; +use Thunk\Verbs\Lifecycle\AggregateStateSummary; use Thunk\Verbs\State; interface StoresEvents @@ -23,5 +23,5 @@ public function get(iterable $ids): LazyCollection; /** @param Event[] $events */ public function write(array $events): bool; - public function allRelatedIds(State $state, bool $singleton = false): Collection; + public function summarize(State $state, bool $singleton = false): AggregateStateSummary; } diff --git a/src/Lifecycle/AggregateStateSummary.php b/src/Lifecycle/AggregateStateSummary.php new file mode 100644 index 00000000..755f7873 --- /dev/null +++ b/src/Lifecycle/AggregateStateSummary.php @@ -0,0 +1,17 @@ +formatRelationshipsForWrite($events)); } - public function allRelatedIds(State $state, bool $singleton = false): Collection + public function summarize(State $state, bool $singleton = false): AggregateStateSummary { $known_state_ids = $singleton ? new Collection : Collection::make([$state->id]); $known_event_ids = VerbStateEvent::query() @@ -91,7 +93,22 @@ public function allRelatedIds(State $state, bool $singleton = false): Collection } while ($discovered_event_ids->isNotEmpty()); - return $known_event_ids->sort(); + $aggregates = VerbSnapshot::query() + ->toBase() + ->tap(fn (BaseBuilder $query) => $query->select([ + $this->aggregateExpression($query, 'last_event_id', 'min'), + $this->aggregateExpression($query, 'last_event_id', 'max'), + ])) + ->whereIn('state_id', $known_state_ids) + ->first(); + + return new AggregateStateSummary( + state: $state, + related_event_ids: $discovered_event_ids, + related_state_ids: $discovered_state_ids, + min_applied_event_id: $aggregates->min_last_event_id, + max_applied_event_id: $aggregates->max_last_event_id, + ); } protected function readEvents( @@ -124,11 +141,7 @@ protected function guardAgainstConcurrentWrites(array $events): void $query->select([ 'state_type', 'state_id', - DB::raw(sprintf( - 'max(%s) as %s', - $query->getGrammar()->wrap('event_id'), - $query->getGrammar()->wrapTable('max_event_id') - )), + $this->aggregateExpression($query, 'event_id', 'max'), ]); $query->groupBy('state_type', 'state_id'); @@ -193,4 +206,14 @@ protected function formatRelationshipsForWrite(array $event_objects): array ])) ->all(); } + + protected function aggregateExpression(BaseBuilder $query, string $column, string $function): Expression + { + return DB::raw(sprintf( + '%s(%s) as %s', + $function, + $query->getGrammar()->wrap($column), + $query->getGrammar()->wrapTable("{$function}_{$column}") + )); + } } diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 2588f05f..907f7fd3 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -134,7 +134,10 @@ protected function reconstitute(State $state, bool $singleton = false): static if (! $this->is_replaying && ! $this->is_reconstituting) { try { $this->is_reconstituting = true; - $this->events->get($this->events->allRelatedIds($state, $singleton)) + + $summary = $this->events->summarize($state, $singleton); + + $this->events->get($summary->related_event_ids) ->filter(function (Event $event) { $last_event_ids = $event->states() ->map(fn (State $state) => $state->last_event_id) @@ -157,6 +160,7 @@ protected function reconstitute(State $state, bool $singleton = false): static return true; }) ->each($this->dispatcher->apply(...)); + } finally { $this->is_reconstituting = false; } diff --git a/src/Testing/EventStoreFake.php b/src/Testing/EventStoreFake.php index f024d80c..2eb49f6e 100644 --- a/src/Testing/EventStoreFake.php +++ b/src/Testing/EventStoreFake.php @@ -13,6 +13,7 @@ use Thunk\Verbs\Contracts\StoresEvents; use Thunk\Verbs\Event; use Thunk\Verbs\Facades\Id; +use Thunk\Verbs\Lifecycle\AggregateStateSummary; use Thunk\Verbs\Lifecycle\MetadataManager; use Thunk\Verbs\State; @@ -57,7 +58,7 @@ public function write(array $events): bool return true; } - public function allRelatedIds(State $state, bool $singleton = false): Collection + public function summarize(State $state, bool $singleton = false): AggregateStateSummary { // FIXME } From a04e5525cc8b9c1514a691182991886cb1e79922 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Fri, 23 Aug 2024 09:49:37 -0400 Subject: [PATCH 15/40] WIP --- src/Contracts/StoresSnapshots.php | 2 ++ src/Lifecycle/EventStore.php | 2 ++ src/Lifecycle/SnapshotStore.php | 5 ++++ src/Lifecycle/StateManager.php | 49 ++++++++++++++++--------------- src/Testing/SnapshotStoreFake.php | 15 ++++++++++ 5 files changed, 50 insertions(+), 23 deletions(-) diff --git a/src/Contracts/StoresSnapshots.php b/src/Contracts/StoresSnapshots.php index eabffbc7..8ddda1cb 100644 --- a/src/Contracts/StoresSnapshots.php +++ b/src/Contracts/StoresSnapshots.php @@ -16,4 +16,6 @@ public function loadSingleton(string $type): ?State; public function write(array $states): bool; public function reset(): bool; + + public function delete(Bits|UuidInterface|AbstractUid|int|string ...$ids): bool; } diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index 7a4f52d9..51ddb471 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -60,6 +60,8 @@ public function write(array $events): bool public function summarize(State $state, bool $singleton = false): AggregateStateSummary { + // FIXME: We probably either need to know the state types or go by snapshot ID + $known_state_ids = $singleton ? new Collection : Collection::make([$state->id]); $known_event_ids = VerbStateEvent::query() ->distinct() diff --git a/src/Lifecycle/SnapshotStore.php b/src/Lifecycle/SnapshotStore.php index d409a097..ba8facc9 100644 --- a/src/Lifecycle/SnapshotStore.php +++ b/src/Lifecycle/SnapshotStore.php @@ -56,6 +56,11 @@ public function write(array $states): bool ); } + public function delete(Bits|UuidInterface|AbstractUid|int|string ...$ids): bool + { + return VerbSnapshot::whereIn('state_id', array_map(Id::from(...), $ids))->delete() === true; + } + public function reset(): bool { VerbSnapshot::truncate(); diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 907f7fd3..43a61d42 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -58,8 +58,7 @@ public function load(Bits|UuidInterface|AbstractUid|int|string $id, string $type } else { // State::__construct() auto-registers the state with the StateManager, so we need to // skip the constructor until we've already set the ID. - $reflect = new ReflectionClass($type); - $state = $reflect->newInstanceWithoutConstructor(); + $state = (new ReflectionClass($type))->newInstanceWithoutConstructor(); $state->id = $id; $state->__construct(); } @@ -137,28 +136,32 @@ protected function reconstitute(State $state, bool $singleton = false): static $summary = $this->events->summarize($state, $singleton); + if ($summary->min_applied_event_id !== $summary->max_applied_event_id) { + $this->snapshots->delete($summary->related_state_ids); + } + $this->events->get($summary->related_event_ids) - ->filter(function (Event $event) { - $last_event_ids = $event->states() - ->map(fn (State $state) => $state->last_event_id) - ->filter(); - - $min = $last_event_ids->min() ?? PHP_INT_MIN; - $max = $last_event_ids->max() ?? PHP_INT_MIN; - - // If all states have had this or future events applied, just ignore them - if ($min >= $event->id && $max >= $event->id) { - return false; - } - - // We should never be in a situation where some events are ahead and - // others are behind, so if that's the case we'll throw an exception - if ($max > $event->id && $min <= $event->id) { - throw new RuntimeException('Trying to apply an event to states that are out of sync.'); - } - - return true; - }) + // ->filter(function (Event $event) { + // $last_event_ids = $event->states() + // ->map(fn (State $state) => $state->last_event_id) + // ->filter(); + // + // $min = $last_event_ids->min() ?? PHP_INT_MIN; + // $max = $last_event_ids->max() ?? PHP_INT_MIN; + // + // // If all states have had this or future events applied, just ignore them + // if ($min >= $event->id && $max >= $event->id) { + // return false; + // } + // + // // We should never be in a situation where some events are ahead and + // // others are behind, so if that's the case we'll throw an exception + // if ($max > $event->id && $min <= $event->id) { + // throw new RuntimeException('Trying to apply an event to states that are out of sync.'); + // } + // + // return true; + // }) ->each($this->dispatcher->apply(...)); } finally { diff --git a/src/Testing/SnapshotStoreFake.php b/src/Testing/SnapshotStoreFake.php index 7667289b..e27b7342 100644 --- a/src/Testing/SnapshotStoreFake.php +++ b/src/Testing/SnapshotStoreFake.php @@ -53,6 +53,21 @@ public function reset(): bool return true; } + public function delete(Bits|UuidInterface|AbstractUid|int|string ...$ids): bool + { + $ids = array_map(Id::from(...), $ids); + + foreach ($this->states as $type => $states) { + foreach ($states as $id => $state) { + if (in_array($id, $ids)) { + uniqid($this->states[$type][$id]); + } + } + } + + return true; + } + public function assertWritten(string|Closure $state, Closure|int|null $callback = null): static { if ($state instanceof Closure) { From 21283eb1609f13baaa68197aa9b1caa3dda4d872 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Mon, 26 Aug 2024 12:02:16 -0500 Subject: [PATCH 16/40] wip --- src/Lifecycle/AggregateStateSummary.php | 1 + src/Lifecycle/EventStore.php | 7 +++- src/Lifecycle/SnapshotStore.php | 4 +- src/Lifecycle/StateManager.php | 54 ++++++++++++++----------- src/Testing/EventStoreFake.php | 4 +- tests/Unit/StateReconstitutionTest.php | 37 ++++++++++++++--- 6 files changed, 74 insertions(+), 33 deletions(-) diff --git a/src/Lifecycle/AggregateStateSummary.php b/src/Lifecycle/AggregateStateSummary.php index 755f7873..ff7ba66d 100644 --- a/src/Lifecycle/AggregateStateSummary.php +++ b/src/Lifecycle/AggregateStateSummary.php @@ -13,5 +13,6 @@ public function __construct( public readonly Collection $related_state_ids, public readonly ?int $min_applied_event_id, public readonly ?int $max_applied_event_id, + public readonly bool $out_of_sync, ) {} } diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index 51ddb471..af31088c 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -98,6 +98,7 @@ public function summarize(State $state, bool $singleton = false): AggregateState $aggregates = VerbSnapshot::query() ->toBase() ->tap(fn (BaseBuilder $query) => $query->select([ + $this->aggregateExpression($query, 'id', 'count'), $this->aggregateExpression($query, 'last_event_id', 'min'), $this->aggregateExpression($query, 'last_event_id', 'max'), ])) @@ -106,10 +107,12 @@ public function summarize(State $state, bool $singleton = false): AggregateState return new AggregateStateSummary( state: $state, - related_event_ids: $discovered_event_ids, - related_state_ids: $discovered_state_ids, + related_event_ids: $known_event_ids, + related_state_ids: $known_state_ids, min_applied_event_id: $aggregates->min_last_event_id, max_applied_event_id: $aggregates->max_last_event_id, + out_of_sync: ($aggregates->count_id && (int) $aggregates->count_id !== count($known_state_ids)) + || $aggregates->min_last_event_id !== $aggregates->max_last_event_id, ); } diff --git a/src/Lifecycle/SnapshotStore.php b/src/Lifecycle/SnapshotStore.php index ba8facc9..41644443 100644 --- a/src/Lifecycle/SnapshotStore.php +++ b/src/Lifecycle/SnapshotStore.php @@ -58,7 +58,9 @@ public function write(array $states): bool public function delete(Bits|UuidInterface|AbstractUid|int|string ...$ids): bool { - return VerbSnapshot::whereIn('state_id', array_map(Id::from(...), $ids))->delete() === true; + $ids = array_map(Id::from(...), $ids); + + return VerbSnapshot::whereIn('state_id', $ids)->delete() === true; } public function reset(): bool diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 43a61d42..a3bc6987 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -51,6 +51,13 @@ public function load(Bits|UuidInterface|AbstractUid|int|string $id, string $type return $state; } + $summary = $this->events->summarize($state); + + // FIXME: + if ($summary->out_of_sync) { + $this->snapshots->delete(...$summary->related_state_ids); + } + if ($state = $this->snapshots->load($id, $type)) { if (! $state instanceof $type) { throw new UnexpectedValueException(sprintf('Expected State <%d> to be of type "%s" but got "%s"', $id, class_basename($type), class_basename($state))); @@ -136,32 +143,33 @@ protected function reconstitute(State $state, bool $singleton = false): static $summary = $this->events->summarize($state, $singleton); - if ($summary->min_applied_event_id !== $summary->max_applied_event_id) { - $this->snapshots->delete($summary->related_state_ids); + // FIXME: + if ($summary->out_of_sync) { + $this->snapshots->delete(...$summary->related_state_ids); } $this->events->get($summary->related_event_ids) - // ->filter(function (Event $event) { - // $last_event_ids = $event->states() - // ->map(fn (State $state) => $state->last_event_id) - // ->filter(); - // - // $min = $last_event_ids->min() ?? PHP_INT_MIN; - // $max = $last_event_ids->max() ?? PHP_INT_MIN; - // - // // If all states have had this or future events applied, just ignore them - // if ($min >= $event->id && $max >= $event->id) { - // return false; - // } - // - // // We should never be in a situation where some events are ahead and - // // others are behind, so if that's the case we'll throw an exception - // if ($max > $event->id && $min <= $event->id) { - // throw new RuntimeException('Trying to apply an event to states that are out of sync.'); - // } - // - // return true; - // }) + ->filter(function (Event $event) { + $last_event_ids = $event->states() + ->map(fn (State $state) => $state->last_event_id) + ->filter(); + + $min = $last_event_ids->min() ?? PHP_INT_MIN; + $max = $last_event_ids->max() ?? PHP_INT_MIN; + + // If all states have had this or future events applied, just ignore them + if ($min >= $event->id && $max >= $event->id) { + return false; + } + + // We should never be in a situation where some events are ahead and + // others are behind, so if that's the case we'll throw an exception + if ($max > $event->id && $min <= $event->id) { + throw new RuntimeException('Trying to apply an event to states that are out of sync.'); + } + + return true; + }) ->each($this->dispatcher->apply(...)); } finally { diff --git a/src/Testing/EventStoreFake.php b/src/Testing/EventStoreFake.php index 2eb49f6e..189ae06f 100644 --- a/src/Testing/EventStoreFake.php +++ b/src/Testing/EventStoreFake.php @@ -60,12 +60,12 @@ public function write(array $events): bool public function summarize(State $state, bool $singleton = false): AggregateStateSummary { - // FIXME + return new AggregateStateSummary($state, collect(), collect(), null, null); } public function get(iterable $ids): LazyCollection { - // FIXME + return new LazyCollection(); } /** @return Collection */ diff --git a/tests/Unit/StateReconstitutionTest.php b/tests/Unit/StateReconstitutionTest.php index a1bba15d..9cf852f3 100644 --- a/tests/Unit/StateReconstitutionTest.php +++ b/tests/Unit/StateReconstitutionTest.php @@ -45,9 +45,10 @@ ->and($state2->counter)->toBe(1); StateReconstitutionTestEvent2::fire(state2_id: $state2_id); + StateReconstitutionTestEvent1::fire(state1_id: $state1_id, state2_id: $state2_id); - expect($state1->counter)->toBe(0) - ->and($state2->counter)->toBe(2); + expect($state1->counter)->toBe(2) + ->and($state2->counter)->toBe(3); Verbs::commit(); app(StateManager::class)->reset(include_storage: true); @@ -55,8 +56,8 @@ $state1 = StateReconstitutionTestState1::load($state1_id); $state2 = StateReconstitutionTestState2::load($state2_id); - expect($state1->counter)->toBe(0) - ->and($state2->counter)->toBe(2); + expect($state1->counter)->toBe(2) + ->and($state2->counter)->toBe(3); }); test('partially up-to-date snapshots', function () { @@ -95,6 +96,32 @@ expect($state2->counter)->toBe(5); }); +test('partially deleted snapshots', function () { + StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=1 + StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=2 + StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=2, 2=3 + StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=2, 2=4 + StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=6, 2=5 + + Verbs::commit(); + + $state1 = StateReconstitutionTestState1::load(1); + $state2 = StateReconstitutionTestState2::load(2); + + expect($state1->counter)->toBe(6) + ->and($state2->counter)->toBe(5); + + VerbSnapshot::query()->where('state_id', 1)->delete(); + + app(StateManager::class)->reset(); + + $state1 = StateReconstitutionTestState1::load(1); + $state2 = StateReconstitutionTestState2::load(2); + + expect($state1->counter)->toBe(6); + expect($state2->counter)->toBe(5); +}); + test('partially up-to-date, but out of sync snapshots', function () { StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=1 $event2 = StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=2 @@ -131,7 +158,7 @@ expect($state1->counter)->toBe(6); expect($state2->counter)->toBe(5); -})->skip('This may actually not be possible'); +}); class StateReconstitutionTestState1 extends State { From fe0161179d0b3d9c76115934e28f2f54ca0b924a Mon Sep 17 00:00:00 2001 From: inxilpro Date: Sun, 15 Sep 2024 14:36:54 +0000 Subject: [PATCH 17/40] Fix styling --- src/Testing/EventStoreFake.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Testing/EventStoreFake.php b/src/Testing/EventStoreFake.php index 189ae06f..137d126e 100644 --- a/src/Testing/EventStoreFake.php +++ b/src/Testing/EventStoreFake.php @@ -65,7 +65,7 @@ public function summarize(State $state, bool $singleton = false): AggregateState public function get(iterable $ids): LazyCollection { - return new LazyCollection(); + return new LazyCollection; } /** @return Collection */ From 91f3d870fdb21a44b07b8d3ebc52bb379702aa65 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Thu, 19 Sep 2024 14:20:18 -0400 Subject: [PATCH 18/40] Move singleton status to the state class --- docs/attributes.md | 49 +++++++++--------- docs/state-hydration-snapshots.md | 23 ++++++--- docs/states.md | 14 ++--- .../Counter/src/Events/IncrementCount.php | 8 +-- .../src/Events/IncrementCountTwice.php | 4 +- examples/Counter/src/States/CountState.php | 4 +- .../Counter/tests/InitializeStateTest.php | 2 +- .../src/Events/GlobalReportGenerated.php | 4 +- .../src/Events/SubscriptionCancelled.php | 3 +- .../src/States/GlobalReportState.php | 4 +- .../Autodiscovery/AppliesToSingletonState.php | 27 ---------- .../Autodiscovery/AppliesToState.php | 5 ++ src/Contracts/StoresEvents.php | 1 - src/Events/VerbsStateInitialized.php | 12 +++-- src/Lifecycle/EventStore.php | 7 ++- src/Lifecycle/StateManager.php | 15 ++++-- src/SingletonState.php | 51 +++++++++++++++++++ src/State.php | 5 -- src/StateFactory.php | 10 +--- src/Testing/EventStoreFake.php | 6 +-- tests/Unit/ConcurrencyTest.php | 4 +- tests/Unit/FactoryTest.php | 10 +++- tests/Unit/UseStatesDirectlyInEventsTest.php | 24 ++++++++- 23 files changed, 169 insertions(+), 123 deletions(-) delete mode 100644 src/Attributes/Autodiscovery/AppliesToSingletonState.php create mode 100644 src/SingletonState.php diff --git a/docs/attributes.md b/docs/attributes.md index 7d05c899..e3e9f34a 100644 --- a/docs/attributes.md +++ b/docs/attributes.md @@ -13,11 +13,15 @@ class YourEvent extends Event } ``` -The `StateId` attribute takes a `state_type`, an optional [`alias`](https://verbs.thunk.dev/docs/reference/states#content-aliasstring-alias-state-state) string, and by default can [automatically generate](/docs/technical/ids#content-automatically-generating-ids)(`autofill`) a `snowflake_id` for you. +The `StateId` attribute takes a `state_type`, an optional [ +`alias`](https://verbs.thunk.dev/docs/reference/states#content-aliasstring-alias-state-state) string, and by default +can [automatically generate](/docs/technical/ids#content-automatically-generating-ids)(`autofill`) a `snowflake_id` for +you. ### `#[AppliesToState]` -Another way to link states and events; like [`StateId`](#content-stateid), but using the attributes above the class instead of on each individual id. +Another way to link states and events; like [`StateId`](#content-stateid), but using the attributes above the class +instead of on each individual id. ```php #[AppliesToState(GameState::class)] @@ -34,7 +38,8 @@ class RolledDice extends Event } ``` -`AppliesToState` has the same params as `StateId`, with an additional optional `id` param (after `state_type`) if you want to specify which prop belongs to which state. +`AppliesToState` has the same params as `StateId`, with an additional optional `id` param (after `state_type`) if you +want to specify which prop belongs to which state. ```php #[AppliesToState(state_type: GameState::class, id: foo_id)] @@ -51,24 +56,8 @@ class RolledDice extends Event } ``` -Otherwise, with `AppliesToState`, Verbs will find the `id` for you based on your State's prefix (i.e. `ExampleState` would be `example`, meaning `example_id` or `example_ids` would be associated automatically). - -### `#[AppliesToSingletonState]` - -Use the `AppliesToSingletonState` attribute on an event class to tell Verbs that it should always be applied to a single state (e.g. `CountState`) across the entire application (as opposed to having different counts for different states). - -Because we're using a [singleton state](/docs/reference/states#content-singleton-states), there is no need for the event to have a `$count_id`. - -```php -#[AppliesToSingletonState(CountState::class)] -class IncrementCount extends Event -{ - public function apply(CountState $state) - { - $state->count++; - } -} -``` +Otherwise, with `AppliesToState`, Verbs will find the `id` for you based on your State's prefix (i.e. `ExampleState` +would be `example`, meaning `example_id` or `example_ids` would be associated automatically). In addition to your `state_type` param, you may also set an optional `alias` string. @@ -76,7 +65,8 @@ In addition to your `state_type` param, you may also set an optional `alias` str Use the `AppliesToChildState` attribute on an event class to allow Verbs to access a nested state. -For our example, let's make sure our `ParentState` has a `child_id` property pointing to a `ChildState` by firing a `ChildAddedToParent` event: +For our example, let's make sure our `ParentState` has a `child_id` property pointing to a `ChildState` by firing a +`ChildAddedToParent` event: ```php ChildAddedToParent::fire(parent_id: 1, child_id: 2); @@ -103,6 +93,7 @@ class ParentState extends State public int $child_id; } ``` + ```php class ChildState extends State { @@ -110,7 +101,8 @@ class ChildState extends State } ``` -Now that `ParentState` has a record of our `ChildState`, we can load the child *through* the parent with `AppliesToChildState`. +Now that `ParentState` has a record of our `ChildState`, we can load the child *through* the parent with +`AppliesToChildState`. Let's show this by firing a `NestedStateAccessed` event with our new attribute: @@ -134,9 +126,12 @@ class NestedStateAccessed extends Event } } ``` -`AppliesToChildState` takes a `state_type` (your child state), `parent_type`, `id` (your child state id), and an optional `alias` string. -When you use `AppliesToChildState`, don't forget to also use `StateId` or [`AppliesToState`](/docs/technical/attributes#content-appliestostate) to identify the `parent_id`. +`AppliesToChildState` takes a `state_type` (your child state), `parent_type`, `id` (your child state id), and an +optional `alias` string. + +When you use `AppliesToChildState`, don't forget to also use `StateId` or [ +`AppliesToState`](/docs/technical/attributes#content-appliestostate) to identify the `parent_id`. -Verbs uses the [Symfony Serializer component](https://symfony.com/components/Serializer) to serialize your PHP Event objects to JSON. +Verbs uses the [Symfony Serializer component](https://symfony.com/components/Serializer) to serialize your PHP Event +objects to JSON. -The default normalizers should handle most stock Laravel applications, but you may need to add your own normalizers for certain object types, which you can do in `config/verbs.php`. +The default normalizers should handle most stock Laravel applications, but you may need to add your own normalizers for +certain object types, which you can do in `config/verbs.php`. -You can also use our interface `SerializedByVerbs` in tandem with trait `NormalizeToPropertiesAndClassName` on classes to support custom types. +You can also use our interface `SerializedByVerbs` in tandem with trait `NormalizeToPropertiesAndClassName` on classes +to support custom types. -You can see good implentation of this in one of our [examples](https://github.com/hirethunk/verbs/blob/main/examples/Monopoly/src/Game/Spaces/Space.php), `examples/Monopoly/src/Game/Spaces/Space.php` +You can see good implentation of this in one of +our [examples](https://github.com/hirethunk/verbs/blob/main/examples/Monopoly/src/Game/Spaces/Space.php), +`examples/Monopoly/src/Game/Spaces/Space.php` diff --git a/docs/states.md b/docs/states.md index 7acc6ac9..5e044776 100644 --- a/docs/states.md +++ b/docs/states.md @@ -151,23 +151,15 @@ Route::get('/users/{user_state}', function(UserState $user_state) { You may want a state that only needs one iteration across the entire application--this is called a singleton state. Singleton states require no id, since there is no need to differentiate among state instances. -In our events that apply to a singleton state, we simply need to use the -`AppliesToSingletonState` [attribute](/docs/technical/attributes#content-appliestosingletonstate). +To tell Verbs to treat a State as a singleton, implement the `SingletonState` interface. ```php -#[AppliesToSingletonState(CountState::class)] -class IncrementCount extends Event +class CountState extends State implements SingletonState { - public function apply(CountState $state) - { - $state->count++; - } + // ... } ``` -This event uses `AppliesToSingletonState` to tell Verbs that it should always be applied to a single `CountState` across -the entire application (as opposed to having different counts for different situations). - ### Loading the singleton state Since singleton's require no IDs, simply call the `singleton()` method. diff --git a/examples/Counter/src/Events/IncrementCount.php b/examples/Counter/src/Events/IncrementCount.php index 2f3c8fa5..8c5d1bc1 100644 --- a/examples/Counter/src/Events/IncrementCount.php +++ b/examples/Counter/src/Events/IncrementCount.php @@ -2,21 +2,21 @@ namespace Thunk\Verbs\Examples\Counter\Events; -use Thunk\Verbs\Attributes\Autodiscovery\AppliesToSingletonState; +use Thunk\Verbs\Attributes\Autodiscovery\AppliesToState; use Thunk\Verbs\Event; use Thunk\Verbs\Examples\Counter\States\CountState; /** * In our most basic example of event sourcing, we just use a single event - * to increment a counter. This event uses `AppliesToSingletonState` to tell - * Verbs that it should always be applied to a single `CountState` across the + * to increment a counter. Because CountState is a SingletonState object, + * Verbs will always apply this event to a single `CountState` across the * entire application (as opposed to having different counts for different * situations). * * Because we're using a singleton state, there is no need for the event to * have a `$count_id`. */ -#[AppliesToSingletonState(CountState::class)] +#[AppliesToState(CountState::class)] class IncrementCount extends Event { public function apply(CountState $state) diff --git a/examples/Counter/src/Events/IncrementCountTwice.php b/examples/Counter/src/Events/IncrementCountTwice.php index 8393dec3..c346eee3 100644 --- a/examples/Counter/src/Events/IncrementCountTwice.php +++ b/examples/Counter/src/Events/IncrementCountTwice.php @@ -2,11 +2,11 @@ namespace Thunk\Verbs\Examples\Counter\Events; -use Thunk\Verbs\Attributes\Autodiscovery\AppliesToSingletonState; +use Thunk\Verbs\Attributes\Autodiscovery\AppliesToState; use Thunk\Verbs\Event; use Thunk\Verbs\Examples\Counter\States\CountState; -#[AppliesToSingletonState(CountState::class)] +#[AppliesToState(CountState::class)] class IncrementCountTwice extends Event { public function handle() diff --git a/examples/Counter/src/States/CountState.php b/examples/Counter/src/States/CountState.php index a2a96d27..edd87e3c 100644 --- a/examples/Counter/src/States/CountState.php +++ b/examples/Counter/src/States/CountState.php @@ -2,9 +2,9 @@ namespace Thunk\Verbs\Examples\Counter\States; -use Thunk\Verbs\State; +use Thunk\Verbs\SingletonState; -class CountState extends State +class CountState extends SingletonState { public int $count = 0; } diff --git a/examples/Counter/tests/InitializeStateTest.php b/examples/Counter/tests/InitializeStateTest.php index 11843a70..9746d3e9 100644 --- a/examples/Counter/tests/InitializeStateTest.php +++ b/examples/Counter/tests/InitializeStateTest.php @@ -7,7 +7,7 @@ use Thunk\Verbs\Models\VerbEvent; it('State factory initializes a state', function () { - $count_state = CountState::factory()->singleton()->create([ + $count_state = CountState::factory()->create([ 'count' => 1337, ]); diff --git a/examples/Subscriptions/src/Events/GlobalReportGenerated.php b/examples/Subscriptions/src/Events/GlobalReportGenerated.php index 75e1d0f8..7f35d97e 100644 --- a/examples/Subscriptions/src/Events/GlobalReportGenerated.php +++ b/examples/Subscriptions/src/Events/GlobalReportGenerated.php @@ -2,13 +2,13 @@ namespace Thunk\Verbs\Examples\Subscriptions\Events; -use Thunk\Verbs\Attributes\Autodiscovery\AppliesToSingletonState; +use Thunk\Verbs\Attributes\Autodiscovery\AppliesToState; use Thunk\Verbs\Attributes\Hooks\Once; use Thunk\Verbs\Event; use Thunk\Verbs\Examples\Subscriptions\Models\Report; use Thunk\Verbs\Examples\Subscriptions\States\GlobalReportState; -#[AppliesToSingletonState(GlobalReportState::class)] +#[AppliesToState(GlobalReportState::class)] class GlobalReportGenerated extends Event { #[Once] diff --git a/examples/Subscriptions/src/Events/SubscriptionCancelled.php b/examples/Subscriptions/src/Events/SubscriptionCancelled.php index 9c800a19..e278c775 100644 --- a/examples/Subscriptions/src/Events/SubscriptionCancelled.php +++ b/examples/Subscriptions/src/Events/SubscriptionCancelled.php @@ -3,7 +3,6 @@ namespace Thunk\Verbs\Examples\Subscriptions\Events; use Thunk\Verbs\Attributes\Autodiscovery\AppliesToChildState; -use Thunk\Verbs\Attributes\Autodiscovery\AppliesToSingletonState; use Thunk\Verbs\Attributes\Autodiscovery\AppliesToState; use Thunk\Verbs\Event; use Thunk\Verbs\Examples\Subscriptions\Models\Subscription; @@ -13,7 +12,7 @@ #[AppliesToState(state_type: SubscriptionState::class, id: 'subscription_id', alias: 'subscription')] #[AppliesToChildState(state_type: PlanReportState::class, parent_type: SubscriptionState::class, id: 'plan_id', alias: 'plan')] -#[AppliesToSingletonState(state_type: GlobalReportState::class, alias: 'report')] +#[AppliesToState(state_type: GlobalReportState::class, alias: 'report')] class SubscriptionCancelled extends Event { public int $subscription_id; diff --git a/examples/Subscriptions/src/States/GlobalReportState.php b/examples/Subscriptions/src/States/GlobalReportState.php index 127310f0..a1c3f3a9 100644 --- a/examples/Subscriptions/src/States/GlobalReportState.php +++ b/examples/Subscriptions/src/States/GlobalReportState.php @@ -5,9 +5,9 @@ use Illuminate\Support\Carbon; use Thunk\Verbs\Examples\Subscriptions\Events\SubscriptionCancelled; use Thunk\Verbs\Examples\Subscriptions\Events\SubscriptionStarted; -use Thunk\Verbs\State; +use Thunk\Verbs\SingletonState; -class GlobalReportState extends State +class GlobalReportState extends SingletonState { public int $total_subscriptions = 0; diff --git a/src/Attributes/Autodiscovery/AppliesToSingletonState.php b/src/Attributes/Autodiscovery/AppliesToSingletonState.php deleted file mode 100644 index 3d931074..00000000 --- a/src/Attributes/Autodiscovery/AppliesToSingletonState.php +++ /dev/null @@ -1,27 +0,0 @@ -state_type, State::class, true)) { - throw new InvalidArgumentException('You must pass state class names to the "AppliesToSingletonState" attribute.'); - } - } - - public function discoverState(Event $event, StateManager $manager): State - { - return $manager->singleton($this->state_type); - } -} diff --git a/src/Attributes/Autodiscovery/AppliesToState.php b/src/Attributes/Autodiscovery/AppliesToState.php index 32dd227e..7af9d5f4 100644 --- a/src/Attributes/Autodiscovery/AppliesToState.php +++ b/src/Attributes/Autodiscovery/AppliesToState.php @@ -8,6 +8,7 @@ use InvalidArgumentException; use Thunk\Verbs\Event; use Thunk\Verbs\Lifecycle\StateManager; +use Thunk\Verbs\SingletonState; use Thunk\Verbs\State; #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] @@ -26,6 +27,10 @@ public function __construct( public function discoverState(Event $event, StateManager $manager): State|array { + if (is_subclass_of($this->state_type, SingletonState::class)) { + return $this->state_type::singleton(); + } + $property = $this->getStateIdProperty($event); $id = $event->{$property}; diff --git a/src/Contracts/StoresEvents.php b/src/Contracts/StoresEvents.php index aa145e18..057356d8 100644 --- a/src/Contracts/StoresEvents.php +++ b/src/Contracts/StoresEvents.php @@ -14,7 +14,6 @@ interface StoresEvents public function read( ?State $state = null, Bits|UuidInterface|AbstractUid|int|string|null $after_id = null, - bool $singleton = false ): LazyCollection; /** @param Event[] $events */ diff --git a/src/Events/VerbsStateInitialized.php b/src/Events/VerbsStateInitialized.php index 920f1974..84cd3cbc 100644 --- a/src/Events/VerbsStateInitialized.php +++ b/src/Events/VerbsStateInitialized.php @@ -4,6 +4,7 @@ use Thunk\Verbs\CommitsImmediately; use Thunk\Verbs\Event; +use Thunk\Verbs\SingletonState; use Thunk\Verbs\Support\StateCollection; /** @template TStateType */ @@ -14,15 +15,18 @@ public function __construct( public int|string $state_id, public string $state_class, public array $state_data, - public bool $singleton = false, ) {} /** @return StateCollection */ public function states(): StateCollection { - return StateCollection::make([ - $this->singleton ? $this->state_class::singleton() : $this->state_class::load($this->state_id), - ]); + $state = is_subclass_of($this->state_class, SingletonState::class) + ? $this->state_class::singleton() + : $this->state_class::load($this->state_id); + + $state->id = $this->state_id; + + return StateCollection::make([$state]); } public function validate() diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index b68e5c1d..d90223ea 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -16,6 +16,7 @@ use Thunk\Verbs\Facades\Id; use Thunk\Verbs\Models\VerbEvent; use Thunk\Verbs\Models\VerbStateEvent; +use Thunk\Verbs\SingletonState; use Thunk\Verbs\State; use Thunk\Verbs\Support\Serializer; @@ -28,9 +29,8 @@ public function __construct( public function read( ?State $state = null, Bits|UuidInterface|AbstractUid|int|string|null $after_id = null, - bool $singleton = false, ): LazyCollection { - return $this->readEvents($state, $after_id, $singleton) + return $this->readEvents($state, $after_id) ->each(fn (VerbEvent $model) => $this->metadata->set($model->event(), $model->metadata())) ->map(fn (VerbEvent $model) => $model->event()); } @@ -50,12 +50,11 @@ public function write(array $events): bool protected function readEvents( ?State $state, Bits|UuidInterface|AbstractUid|int|string|null $after_id, - bool $singleton, ): LazyCollection { if ($state) { return VerbStateEvent::query() ->with('event') - ->unless($singleton, fn (Builder $query) => $query->where('state_id', $state->id)) + ->unless($state instanceof SingletonState, fn (Builder $query) => $query->where('state_id', $state->id)) ->where('state_type', $state::class) ->when($after_id, fn (Builder $query) => $query->whereRelation('event', 'id', '>', Id::from($after_id))) ->lazyById() diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 344448ab..e3f87a32 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -47,7 +47,12 @@ public function load(Bits|UuidInterface|AbstractUid|iterable|int|string $id, str : $this->loadOne($id, $type); } - /** @param class-string $type */ + /** + * @template TStateClass of State + * + * @param class-string $type + * @return TStateClass + */ public function singleton(string $type): State { // FIXME: If the state we're loading has a last_event_id that's ahead of the registry's last_event_id, we need to re-build the state @@ -56,14 +61,14 @@ public function singleton(string $type): State return $state; } - $state = $this->snapshots->loadSingleton($type) ?? $type::make(); + $state = $this->snapshots->loadSingleton($type) ?? new $type(); $state->id ??= snowflake_id(); // We'll store a reference to it by the type for future singleton access $this->states->put($type, $state); $this->remember($state); - $this->reconstitute($state, singleton: true); + $this->reconstitute($state); return $state; } @@ -180,13 +185,13 @@ protected function loadMany(iterable $ids, string $type): StateCollection ); } - protected function reconstitute(State $state, bool $singleton = false): static + protected function reconstitute(State $state): static { // When we're replaying, the Broker is in charge of applying the correct events // to the State, so we only need to do it *outside* of replays. if (! $this->is_replaying) { $this->events - ->read(state: $state, after_id: $state->last_event_id, singleton: $singleton) + ->read(state: $state, after_id: $state->last_event_id) ->each(fn (Event $event) => $this->dispatcher->apply($event)); // It's possible for an event to mutate state out of order when reconstituting, diff --git a/src/SingletonState.php b/src/SingletonState.php new file mode 100644 index 00000000..a0167f19 --- /dev/null +++ b/src/SingletonState.php @@ -0,0 +1,51 @@ +singleton(static::class); + } + + public function resolveRouteBinding($value, $field = null) + { + return static::singleton(); + } + + public function resolveChildRouteBinding($childType, $value, $field) + { + throw new RuntimeException('Resolving child state via routing is not supported.'); + } +} diff --git a/src/State.php b/src/State.php index 17b1e57d..8d243e47 100644 --- a/src/State.php +++ b/src/State.php @@ -87,11 +87,6 @@ protected static function normalizeKey(mixed $from) : $from; } - public static function singleton(): static - { - return app(StateManager::class)->singleton(static::class); - } - public function storedEvents() { return app(StoresEvents::class) diff --git a/src/StateFactory.php b/src/StateFactory.php index 6023be7d..e41d5bb5 100644 --- a/src/StateFactory.php +++ b/src/StateFactory.php @@ -42,7 +42,6 @@ public function __construct( protected Collection $transformations = new Collection, protected ?int $count = null, protected int|string|null $id = null, - protected bool $singleton = false, protected ?Generator $faker = null, protected Collection $makeCallbacks = new Collection, protected Collection $createCallbacks = new Collection, @@ -93,11 +92,6 @@ public function id(Bits|UuidInterface|AbstractUid|int|string $id): static return $this->clone(['id' => Id::from($id)]); } - public function singleton(bool $singleton = true): static - { - return $this->clone(['singleton' => $singleton]); - } - /** @return TStateType|StateCollection */ public function create(array $data = [], Bits|UuidInterface|AbstractUid|int|string|null $id = null): State|StateCollection { @@ -121,7 +115,7 @@ public function create(array $data = [], Bits|UuidInterface|AbstractUid|int|stri return StateCollection::make([$this->createState()]); } - if ($this->singleton) { + if (is_subclass_of($this->state_class, SingletonState::class)) { throw new RuntimeException('You cannot create multiple singleton states of the same type.'); } @@ -146,7 +140,6 @@ protected function createState(): State state_id: $this->id ?? Id::make(), state_class: $this->state_class, state_data: $this->getRawData(), - singleton: $this->singleton, ) : $this->initial_event::fire( ...$this->getRawData(), @@ -179,7 +172,6 @@ protected function clone(array $with = []): static transformations: $with['transformations'] ?? $this->transformations, count: $with['count'] ?? $this->count, id: $with['id'] ?? $this->id, - singleton: $with['singleton'] ?? $this->singleton, faker: $with['faker'] ?? $this->faker, makeCallbacks: $with['makeCallbacks'] ?? $this->makeCallbacks, createCallbacks: $with['createCallbacks'] ?? $this->createCallbacks, diff --git a/src/Testing/EventStoreFake.php b/src/Testing/EventStoreFake.php index 508ac711..186b569c 100644 --- a/src/Testing/EventStoreFake.php +++ b/src/Testing/EventStoreFake.php @@ -14,6 +14,7 @@ use Thunk\Verbs\Event; use Thunk\Verbs\Facades\Id; use Thunk\Verbs\Lifecycle\MetadataManager; +use Thunk\Verbs\SingletonState; use Thunk\Verbs\State; class EventStoreFake implements StoresEvents @@ -32,15 +33,14 @@ public function __construct( public function read( ?State $state = null, UuidInterface|string|int|AbstractUid|Bits|null $after_id = null, - bool $singleton = false ): LazyCollection { return LazyCollection::make($this->events) ->flatten() ->when($after_id, function (LazyCollection $events, $after_id) { return $events->filter(fn (Event $event) => $event->id > Id::from($after_id)); }) - ->when($state, function (LazyCollection $events, State $state) use ($singleton) { - return $singleton + ->when($state, function (LazyCollection $events, State $state) { + return $state instanceof SingletonState ? $events->filter(fn (Event $event) => $event->state($state::class) !== null) : $events->filter(fn (Event $event) => $event->state($state::class)?->id === $state->id); }) diff --git a/tests/Unit/ConcurrencyTest.php b/tests/Unit/ConcurrencyTest.php index 00dc7fe9..76b59833 100644 --- a/tests/Unit/ConcurrencyTest.php +++ b/tests/Unit/ConcurrencyTest.php @@ -4,7 +4,7 @@ use Thunk\Verbs\Exceptions\ConcurrencyException; use Thunk\Verbs\Lifecycle\EventStore; use Thunk\Verbs\Models\VerbEvent; -use Thunk\Verbs\State; +use Thunk\Verbs\SingletonState; use Thunk\Verbs\Support\StateCollection; it('does not throw on sequential events', function () { @@ -49,4 +49,4 @@ public function states(): StateCollection } } -class ConcurrencyTestState extends State {} +class ConcurrencyTestState extends SingletonState {} diff --git a/tests/Unit/FactoryTest.php b/tests/Unit/FactoryTest.php index e6897359..c846be11 100644 --- a/tests/Unit/FactoryTest.php +++ b/tests/Unit/FactoryTest.php @@ -2,6 +2,7 @@ use Illuminate\Support\Collection; use Thunk\Verbs\Lifecycle\StateManager; +use Thunk\Verbs\SingletonState; use Thunk\Verbs\State; use Thunk\Verbs\StateFactory; @@ -56,11 +57,11 @@ }); test('it can create a singleton state', function () { - $singleton_state = FactoryTestState::factory()->singleton()->create(); + $singleton_state = FactoryTestSingletonState::factory()->create(); expect($singleton_state->id)->not->toBeNull(); - $retreived_state = app(StateManager::class)->singleton(FactoryTestState::class); + $retreived_state = app(StateManager::class)->singleton(FactoryTestSingletonState::class); expect($retreived_state)->toBe($singleton_state); }); @@ -91,6 +92,11 @@ class FactoryTestState extends State public string $name; } +class FactoryTestSingletonState extends SingletonState +{ + public string $name; +} + class CustomFactoryTestState extends State { public string $name; diff --git a/tests/Unit/UseStatesDirectlyInEventsTest.php b/tests/Unit/UseStatesDirectlyInEventsTest.php index 110c0475..4e743dce 100644 --- a/tests/Unit/UseStatesDirectlyInEventsTest.php +++ b/tests/Unit/UseStatesDirectlyInEventsTest.php @@ -1,6 +1,7 @@ user_request->acknowledged = true; + } +} + class UserRequestsProcessed extends Event { public function __construct( From fe66df141191dd25dda8b0cde6666cc6f7881aa6 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Fri, 20 Sep 2024 14:30:23 -0400 Subject: [PATCH 19/40] A little bit of docs --- docs/states.md | 12 +++++------- docs/testing.md | 42 +++++++++++++++++++++++------------------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/docs/states.md b/docs/states.md index 5e044776..1598cadf 100644 --- a/docs/states.md +++ b/docs/states.md @@ -118,9 +118,6 @@ in [event lifecycle](/docs/technical/event-lifecycle). ## Loading a State -All state instances are singletons, scoped to an [id](/docs/technical/ids). i.e. say we had a Card Game app--if we apply -a `CardDiscarded` event, we make sure only the `CardState` state with its globablly unique `card_id` is affected. - To retrieve the State, simply call load: ```php @@ -148,10 +145,10 @@ Route::get('/users/{user_state}', function(UserState $user_state) { ## Singleton States -You may want a state that only needs one iteration across the entire application--this is called a singleton state. -Singleton states require no id, since there is no need to differentiate among state instances. +You may want a state that only needs one iteration across the entire application—this is called a singleton state. +Singleton states require no ID because there is only ever one copy in existence across your entire app. -To tell Verbs to treat a State as a singleton, implement the `SingletonState` interface. +To tell Verbs to treat a State as a singleton, extend the `SingletonState` class, rather than `State`. ```php class CountState extends State implements SingletonState @@ -162,7 +159,8 @@ class CountState extends State implements SingletonState ### Loading the singleton state -Since singleton's require no IDs, simply call the `singleton()` method. +Since singletons require no IDs, simply call the `singleton()` method. Trying to load a singleton state in any +other way will result in a `BadMethodCall` exception. ```php YourState::singleton(); diff --git a/docs/testing.md b/docs/testing.md index 1534ac23..fa757dbe 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4,7 +4,8 @@ We enjoy improving Verbs by providing easy, readable testing affordances. When testing verbs events, you'll need to call [commit](/docs/reference/events#content-committing) manually. -You may continue manually adding `Verbs::commit()` after each `Event::fire()` method; however, we've created `Verbs::commitImmediately` to issue a blanket commit on all events you fire in tests. +You may continue manually adding `Verbs::commit()` after each `Event::fire()` method; however, we've created +`Verbs::commitImmediately` to issue a blanket commit on all events you fire in tests. ```php beforeEach(function () { @@ -19,7 +20,8 @@ You may also implement the `CommitsImmediately` interface directly on an Event. The following Test `assert()` methods are available to thoroughly check your committing granularly. -Before using these methods, add `Verbs::fake()` to your test so Verbs can set up a fake event store to isolate the testing environment. +Before using these methods, add `Verbs::fake()` to your test so Verbs can set up a fake event store to isolate the +testing environment. ```php Verbs::assertNothingCommitted(); @@ -29,9 +31,11 @@ Verbs::assertNotCommitted(...); ## State Factories -In tests, you may find yourself needing to fire and commit several events in order to bring your State to the point where it actually needs testing. +In tests, you may find yourself needing to fire and commit several events in order to bring your State to the point +where it actually needs testing. -The `State::factory()` method allows you to bypass manually building up the State, functioning similarly to `Model::factory()`. +The `State::factory()` method allows you to bypass manually building up the State, functioning similarly to +`Model::factory()`. This allows you to call: @@ -58,15 +62,20 @@ Or, in the case of a [singleton state](/docs/reference/states#content-singleton- ChurnState::factory()->create(['churn' => 40]); ``` -Next, we'll get into how these factories work, and continue after with some [Verbs factory methods](testing#content-factory-methods) you may already be familiar with from Eloquent factories. +Next, we'll get into how these factories work, and continue after with +some [Verbs factory methods](testing#content-factory-methods) you may already be familiar with from Eloquent factories. ### `VerbsStateInitialized` -Under the hood, these methods will fire (and immediately commit) a new `VerbsStateInitialized` event, which will fire onto the given state, identified by the id argument (if id is null, we assume it is a singleton) and return a copy of that state. +Under the hood, these methods will fire (and immediately commit) a new `VerbsStateInitialized` event, which will fire +onto the given state, identified by the id argument (if id is null, we assume it is a singleton) and return a copy of +that state. -This is primarily designed for booting up states for testing. If you are migrating non-event-sourced codebases to Verbs, when there is a need to initiate a state for legacy data, it's better to create a custom `MigratedFromLegacy` event. +This is primarily designed for booting up states for testing. If you are migrating non-event-sourced codebases to Verbs, +when there is a need to initiate a state for legacy data, it's better to create a custom `MigratedFromLegacy` event. -You may also change the initial event fired from the StateFactory from `VerbsStateInitialized` to an event class of your choosing by setting an `$intial_event` property on your State Factory. +You may also change the initial event fired from the StateFactory from `VerbsStateInitialized` to an event class of your +choosing by setting an `$intial_event` property on your State Factory. ```php class ExampleStateFactory extends StateFactory @@ -75,11 +84,13 @@ class ExampleStateFactory extends StateFactory } ``` -`VerbsStateInitialized` implements the `CommitsImmediately` interface detailed [above](testing#content-verbscommit), so if you change from this initial event makes sure to extend the interface on your replacement event. +`VerbsStateInitialized` implements the `CommitsImmediately` interface detailed [above](testing#content-verbscommit), so +if you change from this initial event makes sure to extend the interface on your replacement event. ### Factory Methods -Some methods accept Verbs [IDs](/docs/technical/ids), which, written longform, could be any of these types: `Bits|UuidInterface|AbstractUid|int|string`. +Some methods accept Verbs [IDs](/docs/technical/ids), which, written longform, could be any of these types: +`Bits|UuidInterface|AbstractUid|int|string`. For brevity, this will be abbreviated in the following applicable methods as `Id`. @@ -99,14 +110,6 @@ Set the state ID explicitly (cannot be used with `count`). UserState::factory()->id(123)->create(); ``` -#### `singleton()` - -Mark that this is a singleton state (cannot be used with `count`). - -```php -UserState::factory()->singleton()->create(); -``` - #### `state(callable|array $data)` Default data (will be overridden by `create`). @@ -168,7 +171,8 @@ If you'd like to chain behavior after your Factory `create()` executes, do so in #### `configure()` -The configure method in your custom factory allows you to set `afterMaking` and `afterCreating` effects (see [laravel docs](https://laravel.com/docs/11.x/eloquent-factories#factory-callbacks)). +The configure method in your custom factory allows you to set `afterMaking` and `afterCreating` effects ( +see [laravel docs](https://laravel.com/docs/11.x/eloquent-factories#factory-callbacks)). ##### `afterMaking()` & `afterCreating()` From 5cd6c4506a80dd9f349ccd02fc6e99434d349f95 Mon Sep 17 00:00:00 2001 From: inxilpro Date: Fri, 20 Sep 2024 18:30:45 +0000 Subject: [PATCH 20/40] Fix styling --- src/Lifecycle/StateManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index e3f87a32..6765dbb6 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -61,7 +61,7 @@ public function singleton(string $type): State return $state; } - $state = $this->snapshots->loadSingleton($type) ?? new $type(); + $state = $this->snapshots->loadSingleton($type) ?? new $type; $state->id ??= snowflake_id(); // We'll store a reference to it by the type for future singleton access From 3ea993a3abc891a78b898f2683a3b7ccbb95e603 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 25 Sep 2024 13:26:47 -0400 Subject: [PATCH 21/40] Start to refactor --- src/Contracts/StoresEvents.php | 2 +- src/Lifecycle/AggregateStateSummary.php | 83 ++++++++++++++-- src/Lifecycle/EventStore.php | 58 +---------- src/Lifecycle/StateManager.php | 7 +- src/Support/StateIdentity.php | 35 +++++++ src/Testing/EventStoreFake.php | 5 +- tests/Unit/AggregateStateSummaryTest.php | 120 +++++++++++++++++++++++ 7 files changed, 241 insertions(+), 69 deletions(-) create mode 100644 src/Support/StateIdentity.php create mode 100644 tests/Unit/AggregateStateSummaryTest.php diff --git a/src/Contracts/StoresEvents.php b/src/Contracts/StoresEvents.php index a6fe94f4..47619319 100644 --- a/src/Contracts/StoresEvents.php +++ b/src/Contracts/StoresEvents.php @@ -22,5 +22,5 @@ public function get(iterable $ids): LazyCollection; /** @param Event[] $events */ public function write(array $events): bool; - public function summarize(State $state, bool $singleton = false): AggregateStateSummary; + public function summarize(State ...$states): AggregateStateSummary; } diff --git a/src/Lifecycle/AggregateStateSummary.php b/src/Lifecycle/AggregateStateSummary.php index ff7ba66d..2ff61a09 100644 --- a/src/Lifecycle/AggregateStateSummary.php +++ b/src/Lifecycle/AggregateStateSummary.php @@ -2,17 +2,88 @@ namespace Thunk\Verbs\Lifecycle; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; +use Thunk\Verbs\Models\VerbStateEvent; use Thunk\Verbs\State; +use Thunk\Verbs\Support\StateIdentity; class AggregateStateSummary { + public static function summarize(State ...$states): static + { + $summary = new static( + original_states: Collection::make($states), + related_event_ids: new Collection(), + related_states: Collection::make($states)->map(StateIdentity::from(...)), + ); + + return $summary->discover(); + } + + /** + * @param Collection $original_states + * @param Collection $related_event_ids + * @param Collection $related_states + */ public function __construct( - public readonly State $state, - public readonly Collection $related_event_ids, - public readonly Collection $related_state_ids, - public readonly ?int $min_applied_event_id, - public readonly ?int $max_applied_event_id, - public readonly bool $out_of_sync, + public Collection $original_states = new Collection(), + public Collection $related_event_ids = new Collection(), + public Collection $related_states = new Collection(), ) {} + + protected function discover(): static + { + $this->discoverNewEventIds(); + + do { + $continue = $this->discoverNewStates() && $this->discoverNewEventIds(); + } while ($continue); + + return $this; + } + + protected function discoverNewEventIds(): bool + { + $new_event_ids = VerbStateEvent::query() + ->distinct() + ->select('event_id') + ->whereNotIn('event_id', $this->related_event_ids) + ->where(fn (Builder $query) => $this->related_states->each( + fn ($state) => $query->orWhere(fn (Builder $query) => $this->addConstraint($state, $query))) + ) + ->toBase() + ->pluck('event_id'); + + $this->related_event_ids = $this->related_event_ids->merge($new_event_ids); + + return $new_event_ids->isNotEmpty(); + } + + protected function discoverNewStates(): bool + { + $discovered_states = VerbStateEvent::query() + ->distinct() + ->select(['state_id', 'state_type']) + ->whereIn('event_id', $this->related_event_ids) + ->where(fn (Builder $query) => $this->related_states->each( + fn ($state) => $query->whereNot(fn (Builder $query) => $this->addConstraint($state, $query))) + ) + ->toBase() + ->distinct() + ->get() + ->map(StateIdentity::from(...)); + + $this->related_states = $this->related_states->merge($discovered_states); + + return $discovered_states->isNotEmpty(); + } + + protected function addConstraint(StateIdentity $state, Builder $query): Builder + { + $query->where('state_type', '=', $state->state_type); + $query->where('state_id', '=', $state->state_id); + + return $query; + } } diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index e8f31393..eb7e3527 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -16,7 +16,6 @@ use Thunk\Verbs\Exceptions\ConcurrencyException; use Thunk\Verbs\Facades\Id; use Thunk\Verbs\Models\VerbEvent; -use Thunk\Verbs\Models\VerbSnapshot; use Thunk\Verbs\Models\VerbStateEvent; use Thunk\Verbs\SingletonState; use Thunk\Verbs\State; @@ -58,62 +57,9 @@ public function write(array $events): bool && VerbStateEvent::insert($this->formatRelationshipsForWrite($events)); } - public function summarize(State $state, bool $singleton = false): AggregateStateSummary + public function summarize(State ...$states): AggregateStateSummary { - // FIXME: We probably either need to know the state types or go by snapshot ID - - $known_state_ids = $singleton ? new Collection : Collection::make([$state->id]); - $known_event_ids = VerbStateEvent::query() - ->distinct() - ->select('event_id') - ->where('state_type', $state::class) - ->unless($singleton, fn (Builder $query) => $query->where('state_id', $state->id)) - ->toBase() - ->pluck('event_id'); - - do { - $discovered_state_ids = VerbStateEvent::query() - ->distinct() - ->select('state_id') - ->whereIn('event_id', $known_event_ids) - ->whereNotIn('state_id', $known_state_ids) - ->toBase() - ->distinct() - ->pluck('state_id'); - - $known_state_ids = $known_state_ids->merge($discovered_state_ids); - - $discovered_event_ids = VerbStateEvent::query() - ->distinct() - ->select('event_id') - ->whereNotIn('event_id', $known_event_ids) - ->whereIn('state_id', $known_state_ids) - ->toBase() - ->pluck('event_id'); - - $known_event_ids = $known_event_ids->merge($discovered_event_ids); - - } while ($discovered_event_ids->isNotEmpty()); - - $aggregates = VerbSnapshot::query() - ->toBase() - ->tap(fn (BaseBuilder $query) => $query->select([ - $this->aggregateExpression($query, 'id', 'count'), - $this->aggregateExpression($query, 'last_event_id', 'min'), - $this->aggregateExpression($query, 'last_event_id', 'max'), - ])) - ->whereIn('state_id', $known_state_ids) - ->first(); - - return new AggregateStateSummary( - state: $state, - related_event_ids: $known_event_ids, - related_state_ids: $known_state_ids, - min_applied_event_id: $aggregates->min_last_event_id, - max_applied_event_id: $aggregates->max_last_event_id, - out_of_sync: ($aggregates->count_id && (int) $aggregates->count_id !== count($known_state_ids)) - || $aggregates->min_last_event_id !== $aggregates->max_last_event_id, - ); + return AggregateStateSummary::summarize(...$states); } protected function readEvents( diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 6f8811a9..f568fd40 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -210,10 +210,9 @@ protected function reconstitute(State $state): static $summary = $this->events->summarize($state); - // FIXME: - if ($summary->out_of_sync) { - $this->snapshots->delete(...$summary->related_state_ids); - } + // FIXME: We probably need to re-write all the snapshots after we're done + // FIXME: Swap out existing state manager, push all related states into new state manager + // FIXME: run all the event on them, swap them out $this->events->get($summary->related_event_ids) ->filter(function (Event $event) { diff --git a/src/Support/StateIdentity.php b/src/Support/StateIdentity.php new file mode 100644 index 00000000..08247ae3 --- /dev/null +++ b/src/Support/StateIdentity.php @@ -0,0 +1,35 @@ + $source, + $source instanceof State => new static(state_type: $source::class, state_id: $source->id), + default => static::fromGenericObject($source), + }; + } + + protected static function fromGenericObject(object $source): static + { + $state_id = data_get($source, 'state_id'); + $state_type = data_get($source, 'state_type'); + + if (is_int($state_id) && is_string($state_type)) { + return new static(state_type: $state_type, state_id: $state_id); + } + + throw new InvalidArgumentException('State identity objects must have a "state_id" and "state_type" value.'); + } + + public function __construct( + public readonly string $state_type, + public readonly int|string $state_id, + ) {} +} diff --git a/src/Testing/EventStoreFake.php b/src/Testing/EventStoreFake.php index c962e34d..d9fb6523 100644 --- a/src/Testing/EventStoreFake.php +++ b/src/Testing/EventStoreFake.php @@ -58,9 +58,10 @@ public function write(array $events): bool return true; } - public function summarize(State $state, bool $singleton = false): AggregateStateSummary + public function summarize(State ...$states): AggregateStateSummary { - return new AggregateStateSummary($state, collect(), collect(), null, null); + // FIXME + return new AggregateStateSummary($states[0], collect(), collect(), null, null); } public function get(iterable $ids): LazyCollection diff --git a/tests/Unit/AggregateStateSummaryTest.php b/tests/Unit/AggregateStateSummaryTest.php new file mode 100644 index 00000000..3969773b --- /dev/null +++ b/tests/Unit/AggregateStateSummaryTest.php @@ -0,0 +1,120 @@ + $matching_state_id) { + foreach ($matching_event_ids as $matching_event_id) { + VerbStateEvent::insert([ + 'id' => snowflake_id(), + 'event_id' => $matching_event_id, + 'state_id' => $matching_state_id, + 'state_type' => $matching_state_types[$state_index % count($matching_state_types)], + ]); + } + } + + $target_state = new AggregateStateSummaryTestState1(); + $target_state->id = 10; + + $summary = AggregateStateSummary::summarize($target_state); + + expect($summary->original_states->all())->toBe([$target_state]) + ->and($summary->related_states)->toHaveCount(5) + ->and($summary->related_event_ids)->toHaveCount(5); + + $related_state_ids = $summary->related_states + ->map(fn (StateIdentity $state) => $state->state_id) + ->sort() + ->toArray(); + + $related_state_types = $summary->related_states + ->map(fn (StateIdentity $state) => $state->state_type) + ->unique() + ->sort() + ->toArray(); + + expect($related_state_ids)->toBe($matching_state_ids) + ->and($related_state_types)->toBe($matching_state_types); +}); + +test('it finds the correct states and events for multiple states', function () { + $matching_state_types = [ + AggregateStateSummaryTestState1::class, + AggregateStateSummaryTestState2::class, + AggregateStateSummaryTestState3::class, + ]; + $matching_state_ids = [10, 11, 12, 13, 14]; + $matching_event_ids = [100, 101, 102, 103, 105]; + + $other_state_types = [ + AggregateStateSummaryTestState4::class, + AggregateStateSummaryTestState5::class, + AggregateStateSummaryTestState6::class, + ]; + $other_state_ids = [20, 21, 22, 23, 24]; + $other_event_ids = [200, 201, 202, 203, 205]; + + foreach ($matching_state_ids as $state_index => $matching_state_id) { + foreach ($matching_event_ids as $matching_event_id) { + VerbStateEvent::insert([ + 'id' => snowflake_id(), + 'event_id' => $matching_event_id, + 'state_id' => $matching_state_id, + 'state_type' => $matching_state_types[$state_index % count($matching_state_types)], + ]); + } + } + + $target_state1 = new AggregateStateSummaryTestState1(); + $target_state1->id = 10; + + $target_state2 = new AggregateStateSummaryTestState2(); + $target_state2->id = 11; + + $summary = AggregateStateSummary::summarize($target_state1, $target_state2); + + expect($summary->original_states->all())->toBe([$target_state1, $target_state2]) + ->and($summary->related_states)->toHaveCount(5) + ->and($summary->related_event_ids)->toHaveCount(5); + + $related_state_ids = $summary->related_states + ->map(fn (StateIdentity $state) => $state->state_id) + ->sort() + ->toArray(); + + $related_state_types = $summary->related_states + ->map(fn (StateIdentity $state) => $state->state_type) + ->unique() + ->sort() + ->toArray(); + + expect($related_state_ids)->toBe($matching_state_ids) + ->and($related_state_types)->toBe($matching_state_types); +}); + +class AggregateStateSummaryTestState1 extends State {} +class AggregateStateSummaryTestState2 extends State {} +class AggregateStateSummaryTestState3 extends State {} +class AggregateStateSummaryTestState4 extends State {} +class AggregateStateSummaryTestState5 extends State {} +class AggregateStateSummaryTestState6 extends State {} From 8569f3e8d82bd605dad8ab8e97996dbe73352f6b Mon Sep 17 00:00:00 2001 From: inxilpro Date: Thu, 19 Dec 2024 18:46:10 +0000 Subject: [PATCH 22/40] Fix styling --- src/Lifecycle/AggregateStateSummary.php | 8 ++++---- tests/Unit/AggregateStateSummaryTest.php | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Lifecycle/AggregateStateSummary.php b/src/Lifecycle/AggregateStateSummary.php index 2ff61a09..78b82b5e 100644 --- a/src/Lifecycle/AggregateStateSummary.php +++ b/src/Lifecycle/AggregateStateSummary.php @@ -14,7 +14,7 @@ public static function summarize(State ...$states): static { $summary = new static( original_states: Collection::make($states), - related_event_ids: new Collection(), + related_event_ids: new Collection, related_states: Collection::make($states)->map(StateIdentity::from(...)), ); @@ -27,9 +27,9 @@ public static function summarize(State ...$states): static * @param Collection $related_states */ public function __construct( - public Collection $original_states = new Collection(), - public Collection $related_event_ids = new Collection(), - public Collection $related_states = new Collection(), + public Collection $original_states = new Collection, + public Collection $related_event_ids = new Collection, + public Collection $related_states = new Collection, ) {} protected function discover(): static diff --git a/tests/Unit/AggregateStateSummaryTest.php b/tests/Unit/AggregateStateSummaryTest.php index 3969773b..d12a45b3 100644 --- a/tests/Unit/AggregateStateSummaryTest.php +++ b/tests/Unit/AggregateStateSummaryTest.php @@ -33,7 +33,7 @@ } } - $target_state = new AggregateStateSummaryTestState1(); + $target_state = new AggregateStateSummaryTestState1; $target_state->id = 10; $summary = AggregateStateSummary::summarize($target_state); @@ -85,10 +85,10 @@ } } - $target_state1 = new AggregateStateSummaryTestState1(); + $target_state1 = new AggregateStateSummaryTestState1; $target_state1->id = 10; - $target_state2 = new AggregateStateSummaryTestState2(); + $target_state2 = new AggregateStateSummaryTestState2; $target_state2->id = 11; $summary = AggregateStateSummary::summarize($target_state1, $target_state2); From 399288cbad5090c94626d3010905283b13ac6682 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Thu, 19 Dec 2024 16:38:28 -0500 Subject: [PATCH 23/40] wip --- src/Lifecycle/AggregateStateSummary.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Lifecycle/AggregateStateSummary.php b/src/Lifecycle/AggregateStateSummary.php index 78b82b5e..4479c5b6 100644 --- a/src/Lifecycle/AggregateStateSummary.php +++ b/src/Lifecycle/AggregateStateSummary.php @@ -70,9 +70,7 @@ protected function discoverNewStates(): bool fn ($state) => $query->whereNot(fn (Builder $query) => $this->addConstraint($state, $query))) ) ->toBase() - ->distinct() - ->get() - ->map(StateIdentity::from(...)); + ->chunkMap(StateIdentity::from(...)); $this->related_states = $this->related_states->merge($discovered_states); From 00d713ffc74d86bfc40fac4442b6c7ea8f297d40 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Mon, 13 Jan 2025 17:03:04 -0500 Subject: [PATCH 24/40] wip Co-Authored-By: Daniel Coulbourne <429010+DanielCoulbourne@users.noreply.github.com> Co-Authored-By: Skyler Katz --- src/Lifecycle/AggregateStateSummary.php | 3 + src/Lifecycle/NullSnapshotStore.php | 38 ++++++++++++ src/Lifecycle/StateManager.php | 80 +++++++++++++++---------- src/Support/EventStateRegistry.php | 1 + tests/Unit/StateReconstitutionTest.php | 31 +++++++--- 5 files changed, 114 insertions(+), 39 deletions(-) create mode 100644 src/Lifecycle/NullSnapshotStore.php diff --git a/src/Lifecycle/AggregateStateSummary.php b/src/Lifecycle/AggregateStateSummary.php index 4479c5b6..24d3d8f0 100644 --- a/src/Lifecycle/AggregateStateSummary.php +++ b/src/Lifecycle/AggregateStateSummary.php @@ -40,6 +40,8 @@ protected function discover(): static $continue = $this->discoverNewStates() && $this->discoverNewEventIds(); } while ($continue); + $this->related_event_ids = $this->related_event_ids->sort(); + return $this; } @@ -63,6 +65,7 @@ protected function discoverNewEventIds(): bool protected function discoverNewStates(): bool { $discovered_states = VerbStateEvent::query() + ->orderBy('id') ->distinct() ->select(['state_id', 'state_type']) ->whereIn('event_id', $this->related_event_ids) diff --git a/src/Lifecycle/NullSnapshotStore.php b/src/Lifecycle/NullSnapshotStore.php new file mode 100644 index 00000000..5d4879c0 --- /dev/null +++ b/src/Lifecycle/NullSnapshotStore.php @@ -0,0 +1,38 @@ +remember($state); $this->reconstitute($state); - return $state; + return $this->states->get($key); // FIXME } /** @param class-string $type */ @@ -193,59 +193,75 @@ protected function loadMany(iterable $ids, string $type): StateCollection // At this point, all the states should be in our cache, so we can just load everything return StateCollection::make( - $ids->map(fn ($id) => $this->states->get($this->key($id, $type))) + $ids->map(fn ($id) => $this->states->get($this->key($id, $type))), ); } protected function reconstitute(State $state): static { - // When we're replaying, the Broker is in charge of applying the correct events - // to the State, so we need to skip during replays. Similarly, if we're already - // reconstituting in a recursive call, the root call is responsible for applying - // events, so we should also skip in that case. + // FIXME: Only run this if the state is out of date + if (! $this->needsReconstituting($state)) { + // dump('skipping: everything in sync'); + return $this; + } if (! $this->is_replaying && ! $this->is_reconstituting) { + $real_registry = app(EventStateRegistry::class); + try { $this->is_reconstituting = true; $summary = $this->events->summarize($state); - // FIXME: We probably need to re-write all the snapshots after we're done - // FIXME: Swap out existing state manager, push all related states into new state manager - // FIXME: run all the event on them, swap them out - - $this->events->get($summary->related_event_ids) - ->filter(function (Event $event) { - $last_event_ids = $event->states() - ->map(fn (State $state) => $state->last_event_id) - ->filter(); - - $min = $last_event_ids->min() ?? PHP_INT_MIN; - $max = $last_event_ids->max() ?? PHP_INT_MIN; + [$temp_manager] = $this->bindNewEmptyStateManager(); - // If all states have had this or future events applied, just ignore them - if ($min >= $event->id && $max >= $event->id) { - return false; - } - - // We should never be in a situation where some events are ahead and - // others are behind, so if that's the case we'll throw an exception - if ($max > $event->id && $min <= $event->id) { - throw new RuntimeException('Trying to apply an event to states that are out of sync.'); - } - - return true; - }) + $this->events + ->get($summary->related_event_ids) ->each($this->dispatcher->apply(...)); + foreach ($temp_manager->states->all() as $key => $state) { + $this->states->put($key, $state); + } + } finally { $this->is_reconstituting = false; + + app()->instance(StateManager::class, $this); + app()->instance(EventStateRegistry::class, $real_registry); } } return $this; } + protected function needsReconstituting(State $state): bool + { + $max_id = VerbStateEvent::query() + ->where('state_id', $state->id) + ->where('state_type', $state::class) + ->max('event_id'); + + return $max_id !== $state->last_event_id; + } + + protected function bindNewEmptyStateManager() + { + $temp_manager = new StateManager( + dispatcher: $this->dispatcher, + snapshots: new NullSnapshotStore, + events: $this->events, + states: new StateInstanceCache, + ); + $temp_manager->is_reconstituting = true; // FIXME + + $temp_registry = new EventStateRegistry($temp_manager); + + app()->instance(StateManager::class, $temp_manager); + app()->instance(EventStateRegistry::class, $temp_registry); + + return [$temp_manager, $temp_registry]; + } + protected function remember(State $state): State { $key = $this->key($state->id, $state::class); diff --git a/src/Support/EventStateRegistry.php b/src/Support/EventStateRegistry.php index 533c992f..77382c4c 100644 --- a/src/Support/EventStateRegistry.php +++ b/src/Support/EventStateRegistry.php @@ -43,6 +43,7 @@ public function getStates(Event $event): StateCollection protected function discoverStates(Event $event): StateCollection { + dump('Discovering state: '.$event::class." ($event->id)"); $discovered = new StateCollection; $deferred = new StateCollection; diff --git a/tests/Unit/StateReconstitutionTest.php b/tests/Unit/StateReconstitutionTest.php index 9cf852f3..53face52 100644 --- a/tests/Unit/StateReconstitutionTest.php +++ b/tests/Unit/StateReconstitutionTest.php @@ -28,6 +28,11 @@ * - One of those Event::apply methods requires state1 and state2, so we need to load state2 * - Reconstituting state2 re-runs the same apply method on state2 before also running it on state1 * - Double-apply happens + * + * ALTERNATE TEST?: + * + * - LeftState and RightState + * - IncrementLeftByRight and IncrementRightByLeft */ // FIXME: We need to account for partially up-to-date snapshots that only need *some* events applied but not all @@ -61,11 +66,16 @@ }); test('partially up-to-date snapshots', function () { - StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=1 - StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=2 + // event 2 increments state 2 + // event 1 adds state 2 + state 1, then increments state 2 + + $event1 = StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=0, 2=1 + $event2 = StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=0, 2=2 $event3 = StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=2, 2=3 - StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=2, 2=4 - StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=6, 2=5 + $event4 = StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=2, 2=4 + $event5 = StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=6, 2=5 + + dump([$event1->id, $event2->id, $event3->id, $event4->id, $event5->id]); Verbs::commit(); @@ -75,6 +85,8 @@ expect($state1->counter)->toBe(6) ->and($state2->counter)->toBe(5); + // Reset the snapshots to what they looked like at event 3 + $snapshot1 = VerbSnapshot::query()->where('state_id', 1)->sole(); $snapshot1->update([ 'data' => '{"counter":2}', @@ -92,6 +104,9 @@ $state1 = StateReconstitutionTestState1::load(1); $state2 = StateReconstitutionTestState2::load(2); + dump($state1); + dump(VerbSnapshot::all()->toArray()); + expect($state1->counter)->toBe(6); expect($state2->counter)->toBe(5); }); @@ -156,6 +171,8 @@ $state1 = StateReconstitutionTestState1::load(1); $state2 = StateReconstitutionTestState2::load(2); + dump(app(StateManager::class)); + expect($state1->counter)->toBe(6); expect($state2->counter)->toBe(5); }); @@ -180,9 +197,9 @@ class StateReconstitutionTestEvent1 extends \Thunk\Verbs\Event public function apply(StateReconstitutionTestState1 $state1, StateReconstitutionTestState2 $state2): void { - // dump("[event 1] incrementing \$state1->counter from {$state1->counter} to ({$state1->counter} + {$state2->counter})"); + dump("[event 1] incrementing \$state1->counter from {$state1->counter} to ({$state1->counter} + {$state2->counter})"); $state1->counter = $state1->counter + $state2->counter; - // dump("[event 1] incrementing \$state2->counter from {$state2->counter} to \$state2->counter++"); + dump("[event 1] incrementing \$state2->counter from {$state2->counter} to \$state2->counter++"); $state2->counter++; } } @@ -194,7 +211,7 @@ class StateReconstitutionTestEvent2 extends \Thunk\Verbs\Event public function apply(StateReconstitutionTestState2 $state2): void { - // dump("[event 2] incrementing \$state2->counter from {$state2->counter} to \$state2->counter++"); + dump("[event 2] incrementing \$state2->counter from {$state2->counter} to \$state2->counter++"); $state2->counter++; } } From 0b7c2872e31c6e42c964e9132a2e89b2507d24bd Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 15 Jan 2025 13:26:36 -0500 Subject: [PATCH 25/40] wip --- src/Lifecycle/StateManager.php | 27 ++++++++++++- src/Support/EventStateRegistry.php | 10 +++-- src/Support/StateInstanceCache.php | 5 --- src/Support/StateReconstructor.php | 61 ++++++++++++++++++++++++++++++ src/VerbsServiceProvider.php | 6 ++- 5 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 src/Support/StateReconstructor.php diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 9506bed3..b2cd1992 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -18,6 +18,16 @@ use Thunk\Verbs\Support\StateInstanceCache; use UnexpectedValueException; +/* + * Three domains: + * - Loading states from storage + * - Creating new states + * - Recreating states + * + * State managers serves both as a thing that handles + * *all things state* and as a "state locator" + */ + class StateManager { protected bool $is_reconstituting = false; @@ -120,7 +130,7 @@ public function setReplaying(bool $replaying): static public function reset(bool $include_storage = false): static { $this->states->reset(); - app(EventStateRegistry::class)->reset(); + app(EventStateRegistry::class)->reset(); // FIXME: These two classes should be more coupled or decoupled $this->is_replaying = false; @@ -143,6 +153,21 @@ public function prune(): static return $this; } + /** @return State[] */ + public function states(): array + { + return $this->states->values(); + } + + public function push(State $state): static + { + $key = $this->key($state->id, $state::class); + + $this->states->put($key, $state); + + return $this; + } + /** @param class-string $type */ protected function loadOne(Bits|UuidInterface|AbstractUid|int|string $id, string $type): State { diff --git a/src/Support/EventStateRegistry.php b/src/Support/EventStateRegistry.php index 77382c4c..692a4bb5 100644 --- a/src/Support/EventStateRegistry.php +++ b/src/Support/EventStateRegistry.php @@ -2,6 +2,7 @@ namespace Thunk\Verbs\Support; +use Illuminate\Contracts\Container\Container; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use InvalidArgumentException; @@ -26,14 +27,16 @@ class EventStateRegistry protected WeakMap $discovered_states; public function __construct( - protected StateManager $manager, + protected Container $container, ) { $this->discovered_states = new WeakMap; } - public function reset() + public function reset(): static { $this->discovered_states = new WeakMap; + + return $this; } public function getStates(Event $event): StateCollection @@ -43,7 +46,6 @@ public function getStates(Event $event): StateCollection protected function discoverStates(Event $event): StateCollection { - dump('Discovering state: '.$event::class." ($event->id)"); $discovered = new StateCollection; $deferred = new StateCollection; @@ -73,7 +75,7 @@ protected function discoverAndPushState(StateDiscoveryAttribute $attribute, Even $states = Arr::wrap( $attribute ->setDiscoveredState($discovered) - ->discoverState($target, $this->manager), + ->discoverState($target, $this->container->make(StateManager::class)), ); $discovered->push(...$states); diff --git a/src/Support/StateInstanceCache.php b/src/Support/StateInstanceCache.php index b109e3a0..58fa300e 100644 --- a/src/Support/StateInstanceCache.php +++ b/src/Support/StateInstanceCache.php @@ -60,11 +60,6 @@ public function reset(): static return $this; } - public function all(): array - { - return $this->cache; - } - protected function touch($key): void { $value = $this->cache[$key]; diff --git a/src/Support/StateReconstructor.php b/src/Support/StateReconstructor.php new file mode 100644 index 00000000..071426ee --- /dev/null +++ b/src/Support/StateReconstructor.php @@ -0,0 +1,61 @@ +container->make(StateManager::class); + $reconstruction_manager = new StateManager( + dispatcher: $this->dispatcher, + snapshots: new NullSnapshotStore, + events: $this->events, + states: new StateInstanceCache, + ); + + $this->container->instance(StateManager::class, $reconstruction_manager); + + try { + $summary = $this->events->summarize($state); + + $this->events + ->get($summary->related_event_ids) + ->each($this->dispatcher->apply(...)); + + foreach ($reconstruction_manager->states() as $state) { + $manager->push($state); + } + } finally { + $this->container->instance(StateManager::class, $original_manager); + } + + return $original_manager->load($state->id, $state::class); + } + + protected function bindNewEmptyStateManager(StateManager $manager) + { + + $temp_manager->is_reconstituting = true; // FIXME + + $temp_registry = new EventStateRegistry($temp_manager); + + app()->instance(StateManager::class, $temp_manager); + app()->instance(EventStateRegistry::class, $temp_registry); + + return [$temp_manager, $temp_registry]; + } +} diff --git a/src/VerbsServiceProvider.php b/src/VerbsServiceProvider.php index d3f537eb..69099445 100644 --- a/src/VerbsServiceProvider.php +++ b/src/VerbsServiceProvider.php @@ -38,6 +38,7 @@ use Thunk\Verbs\Support\IdManager; use Thunk\Verbs\Support\Serializer; use Thunk\Verbs\Support\StateInstanceCache; +use Thunk\Verbs\Support\StateReconstructor; use Thunk\Verbs\Support\Wormhole; class VerbsServiceProvider extends PackageServiceProvider @@ -66,8 +67,9 @@ public function packageRegistered() $this->app->scoped(EventStore::class); $this->app->singleton(SnapshotStore::class); $this->app->scoped(EventQueue::class); - $this->app->scoped(EventStateRegistry::class); + $this->app->scoped(EventStateRegistry::class); // FIXME: Pretty sure this should be hidden behind the StateManager $this->app->singleton(MetadataManager::class); + $this->app->singleton(StateReconstructor::class); $this->app->scoped(StateManager::class, function (Container $app) { return new StateManager( @@ -76,7 +78,7 @@ public function packageRegistered() events: $app->make(StoresEvents::class), states: new StateInstanceCache( capacity: $app->make(Repository::class)->get('verbs.state_cache_size', 100) - ), + ) ); }); From e9d8b898ee35fcd4322f065da272a7481c9f9655 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Fri, 20 Jun 2025 10:02:59 -0400 Subject: [PATCH 26/40] wip --- BLOCKING_10.md | 30 +++++++++++++++++++++ CLAUDE.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 BLOCKING_10.md create mode 100644 CLAUDE.md diff --git a/BLOCKING_10.md b/BLOCKING_10.md new file mode 100644 index 00000000..bc39a414 --- /dev/null +++ b/BLOCKING_10.md @@ -0,0 +1,30 @@ +Right now, the `StateManager` class is responsible for two separate things: + +1. Act as the role repository for State (since Verbs treats state as singleton, it's important that all state is managed + in one single place) +2. Loading/reconstituting state from storage when it's missing or out of date + +This causes two problems: + +1. We need to prevent state reconstitution when doing replays because State is getting built up over time by the replay + process. This implies that there is an architectural issue with the StateManager +2. Because Verbs allows events to operate on multiple discreet State objects in the same `apply` call, we run into state + sync issues (described below) + +The state sync issue in more detail: + +Imagine a game that has a `PlayerState` and a `GameState`. An event fires called `PlayerEnabledModifier` which is +supposed to enable some special behavior in the game. The event needs to look at the `PlayerState` to see what inventory +they have (to validate that they're allowed to enable the modifier), and then it needs to update the `GameState` to mark +that the modifier is active. + +In this scenario, our existing state reconstitution logic fails if the `PlayerState` and `GameState` snapshots aren't in +sync (maybe writing one snapshot failed for some reason, or it was deleted). Verbs will see that +`PlayerEnabledModifier` requires both the `PlayerState` and `GameState`, and try to load them from the `StateManager`. +When it does, the `StateManager` will FULLY reconstitute whichever state it loads first (ie. apply all events that have +modified that state since the snapshot) before reconstituing the second state. That means that the first state may be +"further ahead" of the second state when applying events to the second state. If one of those events is our +`PlayerEnabledModifier` event, it's possible that it will use future data for a past event. + +Ultimately, all the events that are relevant to the current state of the application as a whole need to be applied in +the order that they fired. This is possible, but will require a rethinking of how Verbs manages state in general. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..f682970d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,71 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Verbs is a Laravel package that provides event sourcing capabilities. It focuses on developer experience, following +Laravel conventions, and minimizing boilerplate. + +## Testing + +The project uses Pest PHP for testing. Key testing patterns: + +```php +// Use Verbs::fake() to prevent database writes during tests +Verbs::fake(); + +// Use Verbs::commitImmediately() for integration tests +Verbs::commitImmediately(); + +// Create test states using factories +CustomerState::factory()->id($id)->create(); +``` + +## High-Level Architecture + +### Core Concepts + +1. **Events**: Immutable records of what happened in the system + - Located in `src/Events/` + - Must extend `Verbs\Event` + - Can implement `boot()`, `authorize()`, `validate()`, `apply()`, and `handle()` methods + +2. **States**: Aggregate event data over time + - Located in `src/States/` + - Must extend `Verbs\State` + - Use `#[StateId]` attribute to specify which event property contains the state ID + +3. **Storage**: Three-table structure + - `verb_events`: All events with metadata + - `verb_snapshots`: State snapshots for performance + - `verb_state_events`: Event-to-state mappings + +### Key Directories + +- `src/`: Main package source code + - `Attributes/`: PHP 8 attributes for configuration + - `Commands/`: Artisan commands + - `Contracts/`: Interfaces + - `Events/`: Base event classes and utilities + - `Facades/`: Laravel facades + - `Models/`: Eloquent models for storage + - `States/`: Base state classes + - `Support/`: Utilities and helpers +- `tests/`: Pest tests organized by feature +- `examples/`: Complete example implementations (Bank, Cart, etc.) + +### Important Patterns + +1. **Event Lifecycle**: boot -> authorize → validate → apply → handle +2. **Attribute Usage**: `#[StateId]`, `#[AppliesToState]`, `#[AppliesToSingletonState]` +3. **Serialization**: Custom normalizers in `src/Support/Normalization/` +4. **Replay Safety**: Use `#[Once]` annotations and `Verbs::unlessReplaying()` for side effects + +## Development Guidelines + +- Follow Laravel package conventions +- Use Pest for all new tests +- Run `composer format` before committing +- Ensure compatibility with PHP 8.1+ and Laravel 10.x, 11.x, 12.x +- Test against SQLite, MySQL, and PostgreSQL when modifying storage logic From b71dff79b19c7f2619786dd8d695721cf8184b46 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Fri, 20 Jun 2025 10:21:33 -0400 Subject: [PATCH 27/40] wip --- BLOCKING_10.md | 224 ++++++++++++++++++++++++++++++++++++++++++++----- CLAUDE.md | 9 ++ 2 files changed, 210 insertions(+), 23 deletions(-) diff --git a/BLOCKING_10.md b/BLOCKING_10.md index bc39a414..75643d7d 100644 --- a/BLOCKING_10.md +++ b/BLOCKING_10.md @@ -1,30 +1,208 @@ -Right now, the `StateManager` class is responsible for two separate things: +# State Synchronization Issue - Blocking Verbs 1.0 -1. Act as the role repository for State (since Verbs treats state as singleton, it's important that all state is managed - in one single place) -2. Loading/reconstituting state from storage when it's missing or out of date +## Problem Statement -This causes two problems: +The `StateManager` class currently conflates two responsibilities that should be separated: -1. We need to prevent state reconstitution when doing replays because State is getting built up over time by the replay - process. This implies that there is an architectural issue with the StateManager -2. Because Verbs allows events to operate on multiple discreet State objects in the same `apply` call, we run into state - sync issues (described below) +1. **State Repository**: Managing the singleton instances of all states in the system +2. **State Reconstitution**: Loading and replaying events to bring states up-to-date -The state sync issue in more detail: +This architectural coupling creates critical synchronization issues when events operate on multiple interdependent +states. -Imagine a game that has a `PlayerState` and a `GameState`. An event fires called `PlayerEnabledModifier` which is -supposed to enable some special behavior in the game. The event needs to look at the `PlayerState` to see what inventory -they have (to validate that they're allowed to enable the modifier), and then it needs to update the `GameState` to mark -that the modifier is active. +## The Core Issue -In this scenario, our existing state reconstitution logic fails if the `PlayerState` and `GameState` snapshots aren't in -sync (maybe writing one snapshot failed for some reason, or it was deleted). Verbs will see that -`PlayerEnabledModifier` requires both the `PlayerState` and `GameState`, and try to load them from the `StateManager`. -When it does, the `StateManager` will FULLY reconstitute whichever state it loads first (ie. apply all events that have -modified that state since the snapshot) before reconstituing the second state. That means that the first state may be -"further ahead" of the second state when applying events to the second state. If one of those events is our -`PlayerEnabledModifier` event, it's possible that it will use future data for a past event. +When an event modifies multiple states (e.g., `PlayerState` and `GameState`), and those states have snapshots at +different points in time, the reconstitution process can lead to: -Ultimately, all the events that are relevant to the current state of the application as a whole need to be applied in -the order that they fired. This is possible, but will require a rethinking of how Verbs manages state in general. +1. **Temporal Inconsistency**: States being reconstituted independently may end up at different points in the event + stream +2. **Circular Dependencies**: Loading State A triggers reconstitution that loads State B, which may trigger loading + State A again +3. **Double Application**: The same event may be applied multiple times during cross-state reconstitution +4. **Future Data Leakage**: Events may see "future" state when one state is reconstituted ahead of another + +## Concrete Example + +```php +class PlayerEnabledModifier extends Event { + public PlayerState $player; + public GameState $game; + + public function validate(PlayerState $player) { + $this->assert($player->hasItem('modifier_token')); + } + + public function apply(PlayerState $player, GameState $game): void { + $game->active_modifiers[] = $this->modifier_id; + } +} +``` + +If `PlayerState` snapshot is at event #100 and `GameState` snapshot is at event #50, reconstituting them independently +means the validation logic sees a future state of the player when processing a past event. + +## Programming Patterns to Address This Issue + +### 1. **Unit of Work Pattern** + +Treat multi-state reconstitution as a single atomic operation: + +```php +class ReconstitutionUnitOfWork { + protected array $states_to_load = []; + protected array $events_to_reapply = []; + + public function addState(string $type, $id): void { + $this->states_to_load[] = [$type, $id]; + } + + public function execute(): array { + $snapshots = $this->loadAllSnapshots(); + $common_event_id = $this->findEarliestSnapshotEventId($snapshots); + $events = $this->loadEventsAfter($common_event_id); + return $this->reapplyEventsInOrder($events, $snapshots); + } +} +``` + +### 2. **Saga Pattern** + +Coordinate state reconstitution as a distributed transaction: + +```php +class ReconstitutionSaga { + protected array $participating_states = []; + + public function begin(): void { + // Mark states as "reconstituting" to prevent concurrent access + } + + public function addParticipant(State $state): void { + $this->participating_states[] = $state; + } + + public function commit(): void { + // Atomically update all states + DB::transaction(function() { + foreach ($this->participating_states as $state) { + $this->snapshots->write($state); + } + }); + } +} +``` + +### 3. **Event Store Pattern with Global Ordering** + +Ensure all events are replayed in global order: + +```php +class GlobalEventStream { + public function getEventsForStates(array $state_ids, int $after_event_id): Collection { + return VerbEvent::query() + ->whereHas('states', fn($q) => $q->whereIn('state_id', $state_ids)) + ->where('id', '>', $after_event_id) + ->orderBy('id') // Global ordering + ->get(); + } +} +``` + +### 4. **Snapshot Coordination Pattern** + +Ensure related states are snapshotted together: + +```php +class CoordinatedSnapshotStore { + public function writeRelatedSnapshots(array $states): void { + $max_event_id = $this->findMaxEventId($states); + + DB::transaction(function() use ($states, $max_event_id) { + foreach ($states as $state) { + $this->writeSnapshot($state, $max_event_id); + } + }); + } +} +``` + +### 5. **Dependency Graph Resolution** + +Track and resolve state dependencies before reconstitution: + +```php +class StateDependencyResolver { + protected array $dependency_graph = []; + + public function analyze(Event $event): array { + // Analyze which states this event affects + $affected_states = $this->getAffectedStates($event); + + // Build dependency graph + foreach ($affected_states as $state) { + $this->dependency_graph[$state->id] = $affected_states; + } + + return $this->topologicalSort($this->dependency_graph); + } +} +``` + +### 6. **Two-Phase Loading Pattern** + +Separate state loading from reconstitution: + +```php +class TwoPhaseStateLoader { + // Phase 1: Load all required states without reconstitution + public function loadStatesWithoutReconstitution(array $state_specs): array { + return collect($state_specs) + ->map(fn($spec) => $this->loadSnapshot($spec)) + ->all(); + } + + // Phase 2: Reconstitute all states together + public function reconstituteTogether(array $states): array { + $min_event_id = collect($states)->min('last_event_id'); + $events = $this->loadEventsAfter($min_event_id); + + foreach ($events as $event) { + $this->applyEventToRelevantStates($event, $states); + } + + return $states; + } +} +``` + +## Recommended Solution Architecture + +1. **Separate Concerns**: Split `StateManager` into: + - `StateRepository`: Manages state instances + - `StateReconstitutor`: Handles replay logic + - `SnapshotCoordinator`: Manages consistent snapshots + +2. **Implement Global Event Ordering**: Ensure events are always replayed in the order they were originally fired + +3. **Atomic Reconstitution**: When loading multiple states, reconstitute them as a single atomic operation + +4. **Consistent Snapshots**: Implement snapshot sets that capture related states at the same logical point + +## Testing Considerations + +Tests should verify: + +- Circular dependency handling +- Consistent state after multi-state reconstitution +- No double-application of events +- Proper handling of partial snapshot scenarios +- Performance with large event streams + +## Migration Path + +1. Add feature flag for new reconstitution logic +2. Implement parallel reconstitution system +3. Add comprehensive tests comparing old vs new behavior +4. Gradually migrate to new system with monitoring +5. Remove old reconstitution code once stable diff --git a/CLAUDE.md b/CLAUDE.md index f682970d..c5b05a88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Verbs is a Laravel package that provides event sourcing capabilities. It focuses on developer experience, following Laravel conventions, and minimizing boilerplate. +## Code Rules + +- Never use `private` or `readonly` keywords +- Never use strict types +- Values are `snake_case` +- Anything callable is `camelCase` (even if it's a variable) +- Paths are `kebab-case` (URLs, files, etc) +- Only apply docblocks where they provide useful IDE/static analysis value + ## Testing The project uses Pest PHP for testing. Key testing patterns: From e4181d2516ff18d4c8c679691bed8f9ca74509f1 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Mon, 7 Jul 2025 11:31:15 -0400 Subject: [PATCH 28/40] wip --- BLOCKING_10.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/BLOCKING_10.md b/BLOCKING_10.md index 75643d7d..b5aa4966 100644 --- a/BLOCKING_10.md +++ b/BLOCKING_10.md @@ -22,6 +22,33 @@ different points in time, the reconstitution process can lead to: 3. **Double Application**: The same event may be applied multiple times during cross-state reconstitution 4. **Future Data Leakage**: Events may see "future" state when one state is reconstituted ahead of another +## Daniel Notes + +### Goals + +- State Reconstitution: given a snapshot (or nothing) and any given number of applicable events, you can + calculate the state. +- State Repository: support the existence of multiple state repos, keeping as much of the singleton behavior + as possible. There should be one "singleton" state repository, but there may be others as needed. We may need + to "swap out" the "current" state repository. + +## Chris Notes + +### Problems + +- We load States in the wrong way internally: the public API of `GameState::load()` is good for userland code, + but we should be loading states in a lower-level way inside Verbs so that we have better control over how the + state is loaded in different contexts. +- The reality of loading state requires ALL the context: we need to know all the states and events that will be + used to build up that state. + +### Ways we load state: + +1. For replay +2. For reconstitution +3. For userland `::load()` contexts +4. For event lifecycle hooks (during fire and replay and to some degree reconstitution) + ## Concrete Example ```php From 5d8cffdbe881ecf3314c2c6c80666c7d0ad5a696 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Mon, 7 Jul 2025 14:48:07 -0400 Subject: [PATCH 29/40] wip --- BLOCKING_10.md | 96 +++++++++++++++++++++++++ src/Lifecycle/AggregateStateSummary.php | 1 + src/Lifecycle/Broker.php | 32 ++++++--- src/Lifecycle/Lifecycle.php | 47 ++++++++++++ src/Lifecycle/Phases.php | 36 ++++++++++ src/Lifecycle/StateRegistry.php | 19 +++++ src/Support/Timeline.php | 25 +++++++ src/Testing/BrokerFake.php | 3 +- 8 files changed, 247 insertions(+), 12 deletions(-) create mode 100644 src/Lifecycle/Lifecycle.php create mode 100644 src/Lifecycle/Phases.php create mode 100644 src/Lifecycle/StateRegistry.php create mode 100644 src/Support/Timeline.php diff --git a/BLOCKING_10.md b/BLOCKING_10.md index b5aa4966..12e84020 100644 --- a/BLOCKING_10.md +++ b/BLOCKING_10.md @@ -42,6 +42,28 @@ different points in time, the reconstitution process can lead to: - The reality of loading state requires ALL the context: we need to know all the states and events that will be used to build up that state. +### State classes + +- Right now we have `StateManager` and `StateInstanceCache` and `State` and `SnapshotStore` and `VerbSnapshot` + +#### `StateRegistry` + +- `get` +- `put` +- has a curryable cache layer + +#### State functions + +- GLOBAL CONTEXT: Load state from storage or cache and maybe reconstitute +- BOTH CONTEXTS: Remember this state (cache) +- INTERNAL CONTEXT: Get this state if you have it (cache) + +- Load state from snapshot +- Apply events to state +- Load state from cache +- Identify whether a state has a snapshot (or needs to be reconstituted) +- Get a default instance of a state + ### Ways we load state: 1. For replay @@ -49,6 +71,18 @@ different points in time, the reconstitution process can lead to: 3. For userland `::load()` contexts 4. For event lifecycle hooks (during fire and replay and to some degree reconstitution) +## Maybe Solutions + +"Everything is a play through of an event stream" + +- Sometimes we play events as they happen +- Sometimes we play them to reconstitute +- Sometimes we play them to replay + +The "unit" is a "Replay" (could be called Saga) in this case. Each replay can have any number of lifecycle hooks +enabled. It's essentially a lazy collection or iterator of an unknown number of events, and builds an internal +collection of states. + ## Concrete Example ```php @@ -233,3 +267,65 @@ Tests should verify: 3. Add comprehensive tests comparing old vs new behavior 4. Gradually migrate to new system with monitoring 5. Remove old reconstitution code once stable + +## Additional Considerations + +### Event Sourcing Best Practices + +The synchronization issue described is a classic problem in event sourcing when dealing with aggregate boundaries. Key +principles to consider: + +- **Aggregate Consistency**: Each aggregate (state) should be internally consistent, but cross-aggregate consistency can + be eventual +- **Process Managers**: For coordinating changes across multiple aggregates, consider implementing process managers that + orchestrate multi-state operations +- **Compensating Events**: When reconstitution fails or produces inconsistent state, having a mechanism for compensating + events could help recovery + +### Reconstitution Context Requirements + +Building on Chris's four loading contexts, each has different consistency requirements: + +1. **Replay Context**: Requires strict ordering and full consistency - all states must be at the exact same point in the + event stream +2. **Reconstitution Context**: Needs consistency within the reconstitution boundary but may tolerate some staleness for + states outside the boundary +3. **Userland ::load() Context**: Could potentially accept eventual consistency depending on use case +4. **Event Lifecycle Context**: Needs point-in-time consistency for the specific moment the event is being processed + +### Performance Optimization Strategies + +Beyond the patterns listed, consider: + +- **Parallel Reconstitution**: For states with no interdependencies, reconstitute in parallel +- **Incremental Reconstitution**: Only reconstitute the delta between snapshot and current state +- **Reconstitution Caching**: Cache recently reconstituted states with TTL based on event frequency +- **Lazy Property Loading**: Defer loading of expensive state properties until accessed + +### Snapshot Coordination Strategies + +Different approaches to maintaining snapshot consistency: + +1. **Event-Aligned Snapshots**: All related states snapshot at the same global event ID +2. **Time-Based Snapshots**: Snapshot all states at regular wall-clock intervals +3. **Logical Clock Snapshots**: Use vector clocks or hybrid logical clocks to maintain causality +4. **Demand-Driven Snapshots**: Snapshot when reconstitution cost exceeds threshold + +### Error Recovery and Debugging + +Important considerations for production systems: + +- **Reconstitution Audit Trail**: Log which events were applied during reconstitution for debugging +- **Deterministic Reconstitution**: Same events + same snapshot should always produce identical state +- **Reconstitution Timeouts**: Prevent infinite loops with configurable timeouts +- **State Corruption Detection**: Checksums or invariant checks to detect corrupted state + +### Testing Strategies Beyond Current List + +Additional test scenarios to consider: + +- **Concurrent Reconstitution**: Multiple threads reconstituting overlapping states +- **Memory Pressure**: Reconstituting very large state graphs +- **Network Partitions**: Handling partial event availability +- **Schema Evolution**: Reconstituting states across event schema changes +- **Reconstitution Determinism**: Verify identical results across multiple runs diff --git a/src/Lifecycle/AggregateStateSummary.php b/src/Lifecycle/AggregateStateSummary.php index 24d3d8f0..b96d0198 100644 --- a/src/Lifecycle/AggregateStateSummary.php +++ b/src/Lifecycle/AggregateStateSummary.php @@ -10,6 +10,7 @@ class AggregateStateSummary { + // FIXME: Maybe pass in all known states AND events public static function summarize(State ...$states): static { $summary = new static( diff --git a/src/Lifecycle/Broker.php b/src/Lifecycle/Broker.php index 533a1222..764743c7 100644 --- a/src/Lifecycle/Broker.php +++ b/src/Lifecycle/Broker.php @@ -37,19 +37,31 @@ public function fire(Event $event): ?Event return null; } - // NOTE: Any changes to how the dispatcher is called here - // should also be applied to the `replay` method - - $this->dispatcher->boot($event); - - Guards::for($event)->check(); + // $hooks = Dispatcher::fireHooks()->diff($disabled_hooks); + // $this->dispatcher->triggerHooks($event, $hooks); - $this->dispatcher->apply($event); + // Lifecycle::for($event, Hooks::fire())->handle(); - $this->queue->queue($event); - - $this->dispatcher->fired($event); + // NOTE: Any changes to how the dispatcher is called here + // should also be applied to the `replay` method + Lifecycle::run( + event: $event, + phases: Phases::fire(), + // onHandle: fn() => $this->queue + ); + + // $this->dispatcher->boot($event); + // + // Guards::for($event)->check(); + // + // $this->dispatcher->apply($event); + // + // $this->queue->queue($event); + // + // $this->dispatcher->fired($event); + + // FIXME if ($this->commit_immediately || $event instanceof CommitsImmediately) { $this->commit(); } diff --git a/src/Lifecycle/Lifecycle.php b/src/Lifecycle/Lifecycle.php new file mode 100644 index 00000000..4d099ace --- /dev/null +++ b/src/Lifecycle/Lifecycle.php @@ -0,0 +1,47 @@ +handle(); + } + + public function __construct( + public Dispatcher $dispatcher, + public Event $event, + public Phases $phases, + ) {} + + public function handle(): Event + { + if ($this->phases->has(Phase::Boot)) { + $this->dispatcher->boot($this->event); + } + + // FIXME: This is actually two phases + if ($this->phases->has(Phase::Authorize)) { + Guards::for($this->event)->check(); + } + + if ($this->phases->has(Phase::Apply)) { + $this->dispatcher->apply($this->event); + } + + // FIXME: Maybe we need a "commit" phase + if ($this->phases->has(Phase::Handle)) { + // FIXME + // $this->queue->queue($this->event); + } + + if ($this->phases->has(Phase::Fired)) { + $this->dispatcher->fired($this->event); + } + + return $this->event; + } +} diff --git a/src/Lifecycle/Phases.php b/src/Lifecycle/Phases.php new file mode 100644 index 00000000..17de1665 --- /dev/null +++ b/src/Lifecycle/Phases.php @@ -0,0 +1,36 @@ +phases = $phases; + } + + public function has(Phase $phase): bool + { + return in_array($phase, $this->phases); + } +} diff --git a/src/Lifecycle/StateRegistry.php b/src/Lifecycle/StateRegistry.php new file mode 100644 index 00000000..176841f2 --- /dev/null +++ b/src/Lifecycle/StateRegistry.php @@ -0,0 +1,19 @@ + Date: Mon, 7 Jul 2025 17:07:50 -0400 Subject: [PATCH 30/40] wip --- BLOCKING_10.md | 22 +++ src/Lifecycle/Lifecycle.php | 4 +- src/Lifecycle/StateManager.php | 126 ++++-------------- src/Lifecycle/StateRegistry.php | 14 +- src/Support/Normalization/StateNormalizer.php | 4 + src/Support/StateInstanceCache.php | 2 +- src/Support/Timeline.php | 21 ++- tests/Feature/TimelineTest.php | 50 +++++++ 8 files changed, 127 insertions(+), 116 deletions(-) create mode 100644 tests/Feature/TimelineTest.php diff --git a/BLOCKING_10.md b/BLOCKING_10.md index 12e84020..cbdc1232 100644 --- a/BLOCKING_10.md +++ b/BLOCKING_10.md @@ -1,5 +1,27 @@ # State Synchronization Issue - Blocking Verbs 1.0 +## When we get back + +Add cache layer to state manager + +```php +$cache = [ + new MemoryCache(), + new RedisCache(), + new DatabaseCache(), +]; + +$cache = [ + new MemoryCache(), + new ReadOnlyCache(new RedisCache()), + new WriteOnlyCache(new DatabaseCache()), +]; +``` + +Look into using Timeline to reconstitute after loading from cache. + +- We need to make sure there's some way to determine if snapshots are in sync + ## Problem Statement The `StateManager` class currently conflates two responsibilities that should be separated: diff --git a/src/Lifecycle/Lifecycle.php b/src/Lifecycle/Lifecycle.php index 4d099ace..9349fc8c 100644 --- a/src/Lifecycle/Lifecycle.php +++ b/src/Lifecycle/Lifecycle.php @@ -6,7 +6,7 @@ class Lifecycle { - public function run(Event $event, Phases $phases): Event + public static function run(Event $event, Phases $phases): Event { return (new static(app(Dispatcher::class), $event, $phases))->handle(); } @@ -32,10 +32,10 @@ public function handle(): Event $this->dispatcher->apply($this->event); } - // FIXME: Maybe we need a "commit" phase if ($this->phases->has(Phase::Handle)) { // FIXME // $this->queue->queue($this->event); + $this->dispatcher->handle($this->event); } if ($this->phases->has(Phase::Fired)) { diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index b2cd1992..70e47d8d 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -9,25 +9,13 @@ use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Contracts\StoresEvents; use Thunk\Verbs\Contracts\StoresSnapshots; -use Thunk\Verbs\Event; use Thunk\Verbs\Facades\Id; -use Thunk\Verbs\Models\VerbStateEvent; use Thunk\Verbs\State; use Thunk\Verbs\Support\EventStateRegistry; use Thunk\Verbs\Support\StateCollection; use Thunk\Verbs\Support\StateInstanceCache; use UnexpectedValueException; -/* - * Three domains: - * - Loading states from storage - * - Creating new states - * - Recreating states - * - * State managers serves both as a thing that handles - * *all things state* and as a "state locator" - */ - class StateManager { protected bool $is_reconstituting = false; @@ -38,7 +26,7 @@ public function __construct( protected Dispatcher $dispatcher, protected StoresSnapshots $snapshots, protected StoresEvents $events, - protected StateInstanceCache $states, + public StateInstanceCache $states, ) {} public function register(State $state): State @@ -56,12 +44,6 @@ public function register(State $state): State */ public function load(Bits|UuidInterface|AbstractUid|iterable|int|string $id, string $type): StateCollection|State { - // FIXME: This was not written to support loading multiple states - // $summary = $this->events->summarize($state); - // if ($summary->out_of_sync) { - // $this->snapshots->delete(...$summary->related_state_ids); - // } - return is_iterable($id) ? $this->loadMany($id, $type) : $this->loadOne($id, $type); @@ -75,22 +57,21 @@ public function load(Bits|UuidInterface|AbstractUid|iterable|int|string $id, str */ public function singleton(string $type): State { - // FIXME: If the state we're loading has a last_event_id that's ahead of the registry's last_event_id, we need to re-build the state - if ($state = $this->states->get($type)) { return $state; } - $state = $this->snapshots->loadSingleton($type) ?? new $type; - $state->id ??= snowflake_id(); + // FIXME + // $state = $this->snapshots->loadSingleton($type) ?? new $type; + // $state->id ??= snowflake_id(); + // + // // We'll store a reference to it by the type for future singleton access + // $this->states->put($type, $state); + // $this->remember($state); + // + // $this->reconstitute($state); - // We'll store a reference to it by the type for future singleton access - $this->states->put($type, $state); - $this->remember($state); - - $this->reconstitute($state); - - return $state; + return $this->make(snowflake_id(), $type); } /** @@ -174,24 +155,23 @@ protected function loadOne(Bits|UuidInterface|AbstractUid|int|string $id, string $id = Id::from($id); $key = $this->key($id, $type); - // FIXME: If the state we're loading has a last_event_id that's ahead of the registry's last_event_id, we need to re-build the state - if ($state = $this->states->get($key)) { return $state; } - if ($state = $this->snapshots->load($id, $type)) { - if (! $state instanceof $type) { - throw new UnexpectedValueException(sprintf('Expected State <%d> to be of type "%s" but got "%s"', $id, class_basename($type), class_basename($state))); - } - } else { - $state = $this->make($id, $type); - } - - $this->remember($state); - $this->reconstitute($state); + // FIXME + // if ($state = $this->snapshots->load($id, $type)) { + // if (! $state instanceof $type) { + // throw new UnexpectedValueException(sprintf('Expected State <%d> to be of type "%s" but got "%s"', $id, class_basename($type), class_basename($state))); + // } + // } else { + // $state = $this->make($id, $type); + // } + // + // $this->remember($state); + // $this->reconstitute($state); - return $this->states->get($key); // FIXME + return $this->make($id, $type); } /** @param class-string $type */ @@ -224,69 +204,9 @@ protected function loadMany(iterable $ids, string $type): StateCollection protected function reconstitute(State $state): static { - // FIXME: Only run this if the state is out of date - if (! $this->needsReconstituting($state)) { - // dump('skipping: everything in sync'); - return $this; - } - - if (! $this->is_replaying && ! $this->is_reconstituting) { - $real_registry = app(EventStateRegistry::class); - - try { - $this->is_reconstituting = true; - - $summary = $this->events->summarize($state); - - [$temp_manager] = $this->bindNewEmptyStateManager(); - - $this->events - ->get($summary->related_event_ids) - ->each($this->dispatcher->apply(...)); - - foreach ($temp_manager->states->all() as $key => $state) { - $this->states->put($key, $state); - } - - } finally { - $this->is_reconstituting = false; - - app()->instance(StateManager::class, $this); - app()->instance(EventStateRegistry::class, $real_registry); - } - } - return $this; } - protected function needsReconstituting(State $state): bool - { - $max_id = VerbStateEvent::query() - ->where('state_id', $state->id) - ->where('state_type', $state::class) - ->max('event_id'); - - return $max_id !== $state->last_event_id; - } - - protected function bindNewEmptyStateManager() - { - $temp_manager = new StateManager( - dispatcher: $this->dispatcher, - snapshots: new NullSnapshotStore, - events: $this->events, - states: new StateInstanceCache, - ); - $temp_manager->is_reconstituting = true; // FIXME - - $temp_registry = new EventStateRegistry($temp_manager); - - app()->instance(StateManager::class, $temp_manager); - app()->instance(EventStateRegistry::class, $temp_registry); - - return [$temp_manager, $temp_registry]; - } - protected function remember(State $state): State { $key = $this->key($state->id, $state::class); diff --git a/src/Lifecycle/StateRegistry.php b/src/Lifecycle/StateRegistry.php index 176841f2..64514918 100644 --- a/src/Lifecycle/StateRegistry.php +++ b/src/Lifecycle/StateRegistry.php @@ -7,13 +7,19 @@ class StateRegistry { public function __construct( - array $caches = [] + array $caches = [], + public array $cache = [], ) {} - public function get(string $class, string $id): State + public function get(string $class, string $id): ?State { - // FIXME + $key = "$class:$id"; + + return $this->cache[$key] ?? null; } - public function put(State $state) {} + public function put(State $state) + { + $this->cache[$state::class.':'.$state->id] = $state; + } } diff --git a/src/Support/Normalization/StateNormalizer.php b/src/Support/Normalization/StateNormalizer.php index 6156c70c..4d842ef2 100644 --- a/src/Support/Normalization/StateNormalizer.php +++ b/src/Support/Normalization/StateNormalizer.php @@ -23,6 +23,10 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a return $data; } + // $state = new $type; + // $state->id = $data; + // $state->__verbs_initialized = false; + return app(StateManager::class)->load($data, $type); } diff --git a/src/Support/StateInstanceCache.php b/src/Support/StateInstanceCache.php index 58fa300e..4c833365 100644 --- a/src/Support/StateInstanceCache.php +++ b/src/Support/StateInstanceCache.php @@ -6,7 +6,7 @@ class StateInstanceCache { public function __construct( protected int $capacity = 100, - protected array $cache = [], + public array $cache = [], ) {} public function get(string|int $key, mixed $default = null): mixed diff --git a/src/Support/Timeline.php b/src/Support/Timeline.php index 25ec6e7d..76fd03da 100644 --- a/src/Support/Timeline.php +++ b/src/Support/Timeline.php @@ -3,22 +3,31 @@ namespace Thunk\Verbs\Support; use Illuminate\Support\Enumerable; +use Thunk\Verbs\Lifecycle\Lifecycle; use Thunk\Verbs\Lifecycle\Phases; +use Thunk\Verbs\Lifecycle\StateManager; class Timeline { public function __construct( - public StateInstanceCache $states, // This should be a new thing that replaces the StateManager, "StateRegistry" + public StateManager $states, public Enumerable $events, + public Phases $phases, ) {} - public function handle(Phases $phases): static + public function handle(): static { - // Loop over events, replay configured hooks, apply snapshots as needed - // Use the Dispatcher to call the appropriate hooks + $global_registry = app(StateManager::class); - // Load GameState + fire event - // - apply to GameState + try { + app()->instance(StateManager::class, $this->states); + + foreach ($this->events as $event) { + Lifecycle::run($event, $this->phases); + } + } finally { + app()->instance(StateManager::class, $global_registry); + } return $this; } diff --git a/tests/Feature/TimelineTest.php b/tests/Feature/TimelineTest.php new file mode 100644 index 00000000..495cb4aa --- /dev/null +++ b/tests/Feature/TimelineTest.php @@ -0,0 +1,50 @@ +event)); + $timeline = new Timeline( + states: new StateManager( + dispatcher: app(Dispatcher::class), + snapshots: app(StoresSnapshots::class), + events: app(StoresEvents::class), + states: new StateInstanceCache, + ), + events: $events, + phases: Phases::all() + ); + + $timeline->handle(); + + expect($timeline->states->states->cache) + ->toHaveCount(1); + + expect($timeline->states->load(1, TimelineTestState::class)->count) + ->toBe(10); +}); + +class TimelineTestEvent extends Event +{ + #[StateId(TimelineTestState::class)] // FIXME: Breaks with State type hint + public int $state; + + public function apply(TimelineTestState $state) + { + $state->count++; + } +} + +class TimelineTestState extends State +{ + public int $count = 0; +} From b21a9eeb8ae9596f993521ba4b08e2d1466191e3 Mon Sep 17 00:00:00 2001 From: Daniel Coulbourne Date: Mon, 7 Jul 2025 22:13:56 -0400 Subject: [PATCH 31/40] WIP --- src/Lifecycle/StateManager.php | 167 +++----------------- src/Lifecycle/StateRegistry.php | 25 --- src/State/Cache/Contracts/ReadableCache.php | 12 ++ src/State/Cache/Contracts/WritableCache.php | 12 ++ src/State/Cache/InMemoryCache.php | 96 +++++++++++ src/State/Cache/MultiCache.php | 10 ++ tests/Feature/TimelineTest.php | 21 +++ 7 files changed, 174 insertions(+), 169 deletions(-) delete mode 100644 src/Lifecycle/StateRegistry.php create mode 100644 src/State/Cache/Contracts/ReadableCache.php create mode 100644 src/State/Cache/Contracts/WritableCache.php create mode 100644 src/State/Cache/InMemoryCache.php create mode 100644 src/State/Cache/MultiCache.php diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 70e47d8d..5e47554c 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -3,37 +3,26 @@ namespace Thunk\Verbs\Lifecycle; use Glhd\Bits\Bits; -use LogicException; use Ramsey\Uuid\UuidInterface; use ReflectionClass; use Symfony\Component\Uid\AbstractUid; -use Thunk\Verbs\Contracts\StoresEvents; -use Thunk\Verbs\Contracts\StoresSnapshots; use Thunk\Verbs\Facades\Id; use Thunk\Verbs\State; -use Thunk\Verbs\Support\EventStateRegistry; +use Thunk\Verbs\State\Cache\Contracts\ReadableCache; +use Thunk\Verbs\State\Cache\Contracts\WritableCache; use Thunk\Verbs\Support\StateCollection; -use Thunk\Verbs\Support\StateInstanceCache; -use UnexpectedValueException; class StateManager { - protected bool $is_reconstituting = false; - - protected bool $is_replaying = false; - public function __construct( - protected Dispatcher $dispatcher, - protected StoresSnapshots $snapshots, - protected StoresEvents $events, - public StateInstanceCache $states, + public ReadableCache&WritableCache $cache, ) {} public function register(State $state): State { $state->id ??= snowflake_id(); - return $this->remember($state); + return $this->cache->put($state); } /** @@ -57,21 +46,7 @@ public function load(Bits|UuidInterface|AbstractUid|iterable|int|string $id, str */ public function singleton(string $type): State { - if ($state = $this->states->get($type)) { - return $state; - } - - // FIXME - // $state = $this->snapshots->loadSingleton($type) ?? new $type; - // $state->id ??= snowflake_id(); - // - // // We'll store a reference to it by the type for future singleton access - // $this->states->put($type, $state); - // $this->remember($state); - // - // $this->reconstitute($state); - - return $this->make(snowflake_id(), $type); + return $this->loadOne($type, null); } /** @@ -80,98 +55,46 @@ public function singleton(string $type): State * @param class-string $type * @return TState */ - public function make(Bits|UuidInterface|AbstractUid|int|string $id, string $type): State + public function make(string $type, Bits|UuidInterface|AbstractUid|int|string|null $id): State { // If we've already instantiated this state, we'll load it - if ($existing = $this->states->get($this->key($id, $type))) { + if ($existing = $this->cache->get($type, $id)) { return $existing; } // State::__construct() auto-registers the state with the StateManager, // so we need to skip the constructor until we've already set the ID. + /** @var TState $state */ $state = (new ReflectionClass($type))->newInstanceWithoutConstructor(); - $state->id = Id::from($id); + $state->id = Id::tryFrom($id) ?? snowflake_id(); $state->__construct(); - return $this->remember($state); - } - - public function writeSnapshots(): bool - { - return $this->snapshots->write($this->states->values()); - } - - public function setReplaying(bool $replaying): static - { - $this->is_replaying = $replaying; - - return $this; - } - - public function reset(bool $include_storage = false): static - { - $this->states->reset(); - app(EventStateRegistry::class)->reset(); // FIXME: These two classes should be more coupled or decoupled - - $this->is_replaying = false; - - if ($include_storage) { - $this->snapshots->reset(); - } - - return $this; - } - - public function willPrune(): bool - { - return $this->states->willPrune(); - } - - public function prune(): static - { - $this->states->prune(); - - return $this; + return $this->cache->put($state); } - /** @return State[] */ - public function states(): array - { - return $this->states->values(); - } + // @todo - make persistent caches + // public function persist(): bool + // { + // return $this->cache->persist($this->states->values()); + // } - public function push(State $state): static + public function reset(): static { - $key = $this->key($state->id, $state::class); - - $this->states->put($key, $state); + $this->cache->reset(); return $this; } /** @param class-string $type */ - protected function loadOne(Bits|UuidInterface|AbstractUid|int|string $id, string $type): State + protected function loadOne(string $type, Bits|UuidInterface|AbstractUid|int|string|null $id = null): State { - $id = Id::from($id); - $key = $this->key($id, $type); + $id = Id::tryFrom($id); - if ($state = $this->states->get($key)) { + if ($state = $this->cache->get($type, $id)) { return $state; } - // FIXME - // if ($state = $this->snapshots->load($id, $type)) { - // if (! $state instanceof $type) { - // throw new UnexpectedValueException(sprintf('Expected State <%d> to be of type "%s" but got "%s"', $id, class_basename($type), class_basename($state))); - // } - // } else { - // $state = $this->make($id, $type); - // } - // - // $this->remember($state); - // $this->reconstitute($state); - - return $this->make($id, $type); + return $this->make($type, $id); } /** @param class-string $type */ @@ -179,53 +102,9 @@ protected function loadMany(iterable $ids, string $type): StateCollection { $ids = collect($ids)->map(Id::from(...)); - $missing = $ids->reject(fn ($id) => $this->states->has($this->key($id, $type))); - - // Load all available snapshots for missing states - $this->snapshots->load($missing, $type)->each(function (State $state) { - $this->remember($state); - $this->reconstitute($state); - }); - - // Then make any states that don't exist yet - $missing - ->reject(fn ($id) => $this->states->has($this->key($id, $type))) - ->each(function (string|int $id) use ($type) { - $state = $this->make($id, $type); - $this->remember($state); - $this->reconstitute($state); - }); - - // At this point, all the states should be in our cache, so we can just load everything return StateCollection::make( - $ids->map(fn ($id) => $this->states->get($this->key($id, $type))), + // @todo - add support for getMany() in caches for perf + $ids->map(fn ($id) => $this->cache->get($type, $id)), ); } - - protected function reconstitute(State $state): static - { - return $this; - } - - protected function remember(State $state): State - { - $key = $this->key($state->id, $state::class); - - if ($this->states->get($key) === $state) { - return $state; - } - - if ($this->states->has($key)) { - throw new LogicException('Trying to remember state twice.'); - } - - $this->states->put($key, $state); - - return $state; - } - - protected function key(string|int $id, string $type): string - { - return "{$type}:{$id}"; - } } diff --git a/src/Lifecycle/StateRegistry.php b/src/Lifecycle/StateRegistry.php deleted file mode 100644 index 64514918..00000000 --- a/src/Lifecycle/StateRegistry.php +++ /dev/null @@ -1,25 +0,0 @@ -cache[$key] ?? null; - } - - public function put(State $state) - { - $this->cache[$state::class.':'.$state->id] = $state; - } -} diff --git a/src/State/Cache/Contracts/ReadableCache.php b/src/State/Cache/Contracts/ReadableCache.php new file mode 100644 index 00000000..92213e4a --- /dev/null +++ b/src/State/Cache/Contracts/ReadableCache.php @@ -0,0 +1,12 @@ +key($class, $id); + + if ($this->has($class, $id)) { + $this->touch($key); + + return $this->cache[$key]; + } + + return null; + } + + public function put(State $state): State + { + $key = $this->key($state); + + if (isset($this->cache[$key])) { + unset($this->cache[$key]); + } + + $this->cache[$key] = $state; + + return $state; + } + + public function has(string $class, string $id): bool + { + $key = $this->key($class, $id); + + return isset($this->cache[$key]); + } + + public function prune(): static + { + $this->cache = array_slice($this->cache, offset: -1 * $this->capacity, preserve_keys: true); + + return $this; + } + + public function willPrune(): bool + { + return count($this->cache) > $this->capacity; + } + + public function values(): array + { + return $this->cache; + } + + public function reset(): static + { + $this->cache = []; + + return $this; + } + + protected function touch($key): void + { + $value = $this->cache[$key]; + + unset($this->cache[$key]); + + $this->cache[$key] = $value; + } + + protected function key(State|string $type, ?string $id = null): string + { + // Allow passing in state objects. + if ($type instanceof State) { + $id = $type instanceof SingletonState + ? null + : $type->id; + + $type = $type::class; + } + + return "{$type}:{$id}"; + } +} diff --git a/src/State/Cache/MultiCache.php b/src/State/Cache/MultiCache.php new file mode 100644 index 00000000..9084f2ed --- /dev/null +++ b/src/State/Cache/MultiCache.php @@ -0,0 +1,10 @@ +toBe(10); }); +it('can cache and retrieve state across events', function () { + $events = collect(array_fill(0, 10, TimelineTestEvent::make(state: 1)->event)); + + $timeline = new Timeline( + states: new StateManager( + dispatcher: app(Dispatcher::class), + events: app(StoresEvents::class), + caches: [ + new InMemoryCache, + // new RedisCache, + // new DatabaseCache + ] + ), + events: $events, + phases: Phases::all() + ); +}); + + + class TimelineTestEvent extends Event { #[StateId(TimelineTestState::class)] // FIXME: Breaks with State type hint From b30d48b2af2a2024a044fcb9b82f0e5b2b7ff3e1 Mon Sep 17 00:00:00 2001 From: DanielCoulbourne <429010+DanielCoulbourne@users.noreply.github.com> Date: Tue, 8 Jul 2025 02:14:19 +0000 Subject: [PATCH 32/40] Fix styling --- src/State/Cache/InMemoryCache.php | 2 +- src/State/Cache/MultiCache.php | 4 +--- tests/Feature/TimelineTest.php | 2 -- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/State/Cache/InMemoryCache.php b/src/State/Cache/InMemoryCache.php index 403370b4..c93976fb 100644 --- a/src/State/Cache/InMemoryCache.php +++ b/src/State/Cache/InMemoryCache.php @@ -83,7 +83,7 @@ protected function touch($key): void protected function key(State|string $type, ?string $id = null): string { // Allow passing in state objects. - if ($type instanceof State) { + if ($type instanceof State) { $id = $type instanceof SingletonState ? null : $type->id; diff --git a/src/State/Cache/MultiCache.php b/src/State/Cache/MultiCache.php index 9084f2ed..c2e24561 100644 --- a/src/State/Cache/MultiCache.php +++ b/src/State/Cache/MultiCache.php @@ -5,6 +5,4 @@ use Thunk\Verbs\State\Cache\Contracts\ReadableCache; use Thunk\Verbs\State\Cache\Contracts\WritableCache; -class MultiCache extends InMemoryCache implements ReadableCache, WritableCache -{ -} +class MultiCache extends InMemoryCache implements ReadableCache, WritableCache {} diff --git a/tests/Feature/TimelineTest.php b/tests/Feature/TimelineTest.php index a36d0b93..f68f6d55 100644 --- a/tests/Feature/TimelineTest.php +++ b/tests/Feature/TimelineTest.php @@ -52,8 +52,6 @@ ); }); - - class TimelineTestEvent extends Event { #[StateId(TimelineTestState::class)] // FIXME: Breaks with State type hint From 391e3526d0991b843d5d549bcd8385a8d6ff6e61 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Mon, 7 Jul 2025 22:15:36 -0400 Subject: [PATCH 33/40] Rename "Timeline" to "Replay" --- src/Support/{Timeline.php => Replay.php} | 2 +- .../{TimelineTest.php => ReplayClassTest.php} | 22 +++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) rename src/Support/{Timeline.php => Replay.php} (98%) rename tests/Feature/{TimelineTest.php => ReplayClassTest.php} (71%) diff --git a/src/Support/Timeline.php b/src/Support/Replay.php similarity index 98% rename from src/Support/Timeline.php rename to src/Support/Replay.php index 76fd03da..d34feb92 100644 --- a/src/Support/Timeline.php +++ b/src/Support/Replay.php @@ -7,7 +7,7 @@ use Thunk\Verbs\Lifecycle\Phases; use Thunk\Verbs\Lifecycle\StateManager; -class Timeline +class Replay { public function __construct( public StateManager $states, diff --git a/tests/Feature/TimelineTest.php b/tests/Feature/ReplayClassTest.php similarity index 71% rename from tests/Feature/TimelineTest.php rename to tests/Feature/ReplayClassTest.php index a36d0b93..28b21e23 100644 --- a/tests/Feature/TimelineTest.php +++ b/tests/Feature/ReplayClassTest.php @@ -9,12 +9,12 @@ use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; use Thunk\Verbs\State\Cache\InMemoryCache; +use Thunk\Verbs\Support\Replay; use Thunk\Verbs\Support\StateInstanceCache; -use Thunk\Verbs\Support\Timeline; it('can rebuild state from events', function () { - $events = collect(array_fill(0, 10, TimelineTestEvent::make(state: 1)->event)); - $timeline = new Timeline( + $events = collect(array_fill(0, 10, ReplayClassTestEvent::make(state: 1)->event)); + $timeline = new Replay( states: new StateManager( dispatcher: app(Dispatcher::class), snapshots: app(StoresSnapshots::class), @@ -30,14 +30,14 @@ expect($timeline->states->states->cache) ->toHaveCount(1); - expect($timeline->states->load(1, TimelineTestState::class)->count) + expect($timeline->states->load(1, ReplayClassTestState::class)->count) ->toBe(10); }); it('can cache and retrieve state across events', function () { - $events = collect(array_fill(0, 10, TimelineTestEvent::make(state: 1)->event)); + $events = collect(array_fill(0, 10, ReplayClassTestEvent::make(state: 1)->event)); - $timeline = new Timeline( + $timeline = new Replay( states: new StateManager( dispatcher: app(Dispatcher::class), events: app(StoresEvents::class), @@ -52,20 +52,18 @@ ); }); - - -class TimelineTestEvent extends Event +class ReplayClassTestEvent extends Event { - #[StateId(TimelineTestState::class)] // FIXME: Breaks with State type hint + #[StateId(ReplayClassTestState::class)] // FIXME: Breaks with State type hint public int $state; - public function apply(TimelineTestState $state) + public function apply(ReplayClassTestState $state) { $state->count++; } } -class TimelineTestState extends State +class ReplayClassTestState extends State { public int $count = 0; } From 0aac134da3f99cd5a539c4908b28b60137ba315a Mon Sep 17 00:00:00 2001 From: Daniel Coulbourne Date: Mon, 7 Jul 2025 22:21:06 -0400 Subject: [PATCH 34/40] WIP --- src/VerbsServiceProvider.php | 8 ++------ tests/Feature/ReplayClassTest.php | 23 +++++++---------------- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/src/VerbsServiceProvider.php b/src/VerbsServiceProvider.php index 69099445..cf213dfd 100644 --- a/src/VerbsServiceProvider.php +++ b/src/VerbsServiceProvider.php @@ -34,6 +34,7 @@ use Thunk\Verbs\Lifecycle\SnapshotStore; use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\Livewire\SupportVerbs; +use Thunk\Verbs\State\Cache\MultiCache; use Thunk\Verbs\Support\EventStateRegistry; use Thunk\Verbs\Support\IdManager; use Thunk\Verbs\Support\Serializer; @@ -73,12 +74,7 @@ public function packageRegistered() $this->app->scoped(StateManager::class, function (Container $app) { return new StateManager( - dispatcher: $app->make(Dispatcher::class), - snapshots: $app->make(StoresSnapshots::class), - events: $app->make(StoresEvents::class), - states: new StateInstanceCache( - capacity: $app->make(Repository::class)->get('verbs.state_cache_size', 100) - ) + cache: new MultiCache ); }); diff --git a/tests/Feature/ReplayClassTest.php b/tests/Feature/ReplayClassTest.php index 28b21e23..f9fb24a2 100644 --- a/tests/Feature/ReplayClassTest.php +++ b/tests/Feature/ReplayClassTest.php @@ -14,38 +14,29 @@ it('can rebuild state from events', function () { $events = collect(array_fill(0, 10, ReplayClassTestEvent::make(state: 1)->event)); - $timeline = new Replay( + $replay = new Replay( states: new StateManager( - dispatcher: app(Dispatcher::class), - snapshots: app(StoresSnapshots::class), - events: app(StoresEvents::class), - states: new StateInstanceCache, + cache: new InMemoryCache ), events: $events, phases: Phases::all() ); - $timeline->handle(); + $replay->handle(); - expect($timeline->states->states->cache) + expect($replay->states->cache) ->toHaveCount(1); - expect($timeline->states->load(1, ReplayClassTestState::class)->count) + expect($replay->states->load(ReplayClassTestState::class, '1')->count) ->toBe(10); }); it('can cache and retrieve state across events', function () { $events = collect(array_fill(0, 10, ReplayClassTestEvent::make(state: 1)->event)); - $timeline = new Replay( + $replay = new Replay( states: new StateManager( - dispatcher: app(Dispatcher::class), - events: app(StoresEvents::class), - caches: [ - new InMemoryCache, - // new RedisCache, - // new DatabaseCache - ] + cache: new InMemoryCache ), events: $events, phases: Phases::all() From 990c56135664a7bd56448e1a957aa4e54408626e Mon Sep 17 00:00:00 2001 From: DanielCoulbourne <429010+DanielCoulbourne@users.noreply.github.com> Date: Tue, 8 Jul 2025 02:21:29 +0000 Subject: [PATCH 35/40] Fix styling --- src/VerbsServiceProvider.php | 1 - tests/Feature/ReplayClassTest.php | 4 ---- 2 files changed, 5 deletions(-) diff --git a/src/VerbsServiceProvider.php b/src/VerbsServiceProvider.php index cf213dfd..ed15c243 100644 --- a/src/VerbsServiceProvider.php +++ b/src/VerbsServiceProvider.php @@ -38,7 +38,6 @@ use Thunk\Verbs\Support\EventStateRegistry; use Thunk\Verbs\Support\IdManager; use Thunk\Verbs\Support\Serializer; -use Thunk\Verbs\Support\StateInstanceCache; use Thunk\Verbs\Support\StateReconstructor; use Thunk\Verbs\Support\Wormhole; diff --git a/tests/Feature/ReplayClassTest.php b/tests/Feature/ReplayClassTest.php index f9fb24a2..5757a147 100644 --- a/tests/Feature/ReplayClassTest.php +++ b/tests/Feature/ReplayClassTest.php @@ -1,16 +1,12 @@ event)); From aeb1076106e0dd44b1391e7338787a806ff6e090 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Mon, 7 Jul 2025 22:23:52 -0400 Subject: [PATCH 36/40] Change order of arguments in state manager methods --- .../Autodiscovery/AppliesToChildState.php | 2 +- src/Attributes/Autodiscovery/AppliesToState.php | 4 ++-- src/Attributes/Autodiscovery/StateId.php | 6 +++--- src/Lifecycle/StateManager.php | 15 ++------------- src/SingletonState.php | 2 +- src/State.php | 4 ++-- src/Support/Normalization/StateNormalizer.php | 2 +- src/Support/StateReconstructor.php | 2 +- tests/Feature/ReplayClassTest.php | 2 +- tests/Feature/ReplayCommandTest.php | 12 ++++++------ tests/Unit/EventStoreFakeTest.php | 4 ++-- 11 files changed, 22 insertions(+), 33 deletions(-) diff --git a/src/Attributes/Autodiscovery/AppliesToChildState.php b/src/Attributes/Autodiscovery/AppliesToChildState.php index 7333ec07..ff7c5cde 100644 --- a/src/Attributes/Autodiscovery/AppliesToChildState.php +++ b/src/Attributes/Autodiscovery/AppliesToChildState.php @@ -35,6 +35,6 @@ public function discoverState(Event $event, StateManager $manager): State { $parent = $this->discovered->first(fn (State $state) => $state instanceof $this->parent_type); - return $manager->load($parent->{$this->id}, $this->state_type); + return $manager->load($this->state_type, $parent->{$this->id}); } } diff --git a/src/Attributes/Autodiscovery/AppliesToState.php b/src/Attributes/Autodiscovery/AppliesToState.php index 7af9d5f4..ea8913b3 100644 --- a/src/Attributes/Autodiscovery/AppliesToState.php +++ b/src/Attributes/Autodiscovery/AppliesToState.php @@ -43,13 +43,13 @@ public function discoverState(Event $event, StateManager $manager): State|array $id = snowflake_id(); $event->{$property} = $id; - return $manager->make($id, $this->state_type); + return $manager->make($this->state_type, $id); } // TODO: Check type of data return collect(Arr::wrap($id)) - ->map(fn ($id) => $manager->load($id, $this->state_type)) + ->map(fn ($id) => $manager->load($this->state_type, $id)) ->all(); } diff --git a/src/Attributes/Autodiscovery/StateId.php b/src/Attributes/Autodiscovery/StateId.php index 6366241d..00bce86a 100644 --- a/src/Attributes/Autodiscovery/StateId.php +++ b/src/Attributes/Autodiscovery/StateId.php @@ -42,15 +42,15 @@ public function discoverState(Event $event, StateManager $manager): State|array $autofilled[$property_name] = true; $meta->put('autofilled', $autofilled); - return $manager->make($id, $this->state_type); + return $manager->make($this->state_type, $id); } // If we autofilled the value when it first fired, then we know this is the // first event for that given state, and we don't need to try to load it if ($meta->get("autofilled.{$property_name}", false)) { - return $manager->make($id, $this->state_type); + return $manager->make($this->state_type, $id); } - return array_map(fn ($id) => $manager->load($id, $this->state_type), Arr::wrap($id)); + return array_map(fn ($id) => $manager->load($this->state_type, $id), Arr::wrap($id)); } } diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 5e47554c..e76e22cc 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -31,24 +31,13 @@ public function register(State $state): State * @param class-string $type * @return S|StateCollection */ - public function load(Bits|UuidInterface|AbstractUid|iterable|int|string $id, string $type): StateCollection|State + public function load(string $type, Bits|UuidInterface|AbstractUid|iterable|int|string|null $id): StateCollection|State { return is_iterable($id) ? $this->loadMany($id, $type) : $this->loadOne($id, $type); } - /** - * @template TStateClass of State - * - * @param class-string $type - * @return TStateClass - */ - public function singleton(string $type): State - { - return $this->loadOne($type, null); - } - /** * @template TState of State * @@ -94,7 +83,7 @@ protected function loadOne(string $type, Bits|UuidInterface|AbstractUid|int|stri return $state; } - return $this->make($type, $id); + return $this->make($id, $type); } /** @param class-string $type */ diff --git a/src/SingletonState.php b/src/SingletonState.php index a0167f19..cc28eb24 100644 --- a/src/SingletonState.php +++ b/src/SingletonState.php @@ -36,7 +36,7 @@ public static function loadByKey($from): static|StateCollection public static function singleton(): static { - return app(StateManager::class)->singleton(static::class); + return app(StateManager::class)->load(static::class, null); } public function resolveRouteBinding($value, $field = null) diff --git a/src/State.php b/src/State.php index 8d243e47..0907ee0e 100644 --- a/src/State.php +++ b/src/State.php @@ -77,7 +77,7 @@ public static function load($from): static|StateCollection public static function loadByKey($from): static|StateCollection { - return app(StateManager::class)->load($from, static::class); + return app(StateManager::class)->load(static::class, $from); } protected static function normalizeKey(mixed $from) @@ -96,7 +96,7 @@ public function storedEvents() public function fresh(): static { - return app(StateManager::class)->load($this->id, static::class); + return app(StateManager::class)->load(static::class, $this->id); } public function getRouteKey() diff --git a/src/Support/Normalization/StateNormalizer.php b/src/Support/Normalization/StateNormalizer.php index 4d842ef2..d64d53da 100644 --- a/src/Support/Normalization/StateNormalizer.php +++ b/src/Support/Normalization/StateNormalizer.php @@ -27,7 +27,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a // $state->id = $data; // $state->__verbs_initialized = false; - return app(StateManager::class)->load($data, $type); + return app(StateManager::class)->load($type, $data); } public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool diff --git a/src/Support/StateReconstructor.php b/src/Support/StateReconstructor.php index 071426ee..0c48ec84 100644 --- a/src/Support/StateReconstructor.php +++ b/src/Support/StateReconstructor.php @@ -43,7 +43,7 @@ public function handle(State $state, StateManager $manager): State $this->container->instance(StateManager::class, $original_manager); } - return $original_manager->load($state->id, $state::class); + return $original_manager->load($state::class, $state->id); } protected function bindNewEmptyStateManager(StateManager $manager) diff --git a/tests/Feature/ReplayClassTest.php b/tests/Feature/ReplayClassTest.php index 5757a147..8a944bb8 100644 --- a/tests/Feature/ReplayClassTest.php +++ b/tests/Feature/ReplayClassTest.php @@ -23,7 +23,7 @@ expect($replay->states->cache) ->toHaveCount(1); - expect($replay->states->load(ReplayClassTestState::class, '1')->count) + expect($replay->states->load('1', ReplayClassTestState::class)->count) ->toBe(10); }); diff --git a/tests/Feature/ReplayCommandTest.php b/tests/Feature/ReplayCommandTest.php index feaf3b27..69fddbfb 100644 --- a/tests/Feature/ReplayCommandTest.php +++ b/tests/Feature/ReplayCommandTest.php @@ -37,11 +37,11 @@ Verbs::commit(); - expect(app(StateManager::class)->load($state1_id, ReplayCommandTestState::class)->count) + expect(app(StateManager::class)->load(ReplayCommandTestState::class, $state1_id)->count) ->toBe(2) ->and($GLOBALS['replay_test_counts'][$state1_id]) ->toBe(2) - ->and(app(StateManager::class)->load($state2_id, ReplayCommandTestState::class)->count) + ->and(app(StateManager::class)->load(ReplayCommandTestState::class, $state2_id)->count) ->toBe(4) ->and($GLOBALS['replay_test_counts'][$state2_id]) ->toBe(4) @@ -54,11 +54,11 @@ config(['app.env' => 'testing']); $this->artisan(ReplayCommand::class); - expect(app(StateManager::class)->load($state1_id, ReplayCommandTestState::class)->count) + expect(app(StateManager::class)->load(ReplayCommandTestState::class, $state1_id)->count) ->toBe(2) ->and($GLOBALS['replay_test_counts'][$state1_id]) ->toBe(2) - ->and(app(StateManager::class)->load($state2_id, ReplayCommandTestState::class)->count) + ->and(app(StateManager::class)->load(ReplayCommandTestState::class, $state2_id)->count) ->toBe(4) ->and($GLOBALS['replay_test_counts'][$state2_id]) ->toBe(4) @@ -72,7 +72,7 @@ Verbs::commit(); - expect(app(StateManager::class)->load($state_id, ReplayCommandTestWormholeState::class)->time->unix()) + expect(app(StateManager::class)->load(ReplayCommandTestWormholeState::class, $state_id)->time->unix()) ->toBe(CarbonImmutable::parse('2024-04-01 12:00:00')->unix()) ->and($GLOBALS['time'][$state_id]->unix()) ->toBe(CarbonImmutable::parse('2024-04-01 12:00:00')->unix()); @@ -83,7 +83,7 @@ config(['app.env' => 'testing']); $this->artisan(ReplayCommand::class); - expect(app(StateManager::class)->load($state_id, ReplayCommandTestWormholeState::class)->time->unix()) + expect(app(StateManager::class)->load(ReplayCommandTestWormholeState::class, $state_id)->time->unix()) ->toBe(CarbonImmutable::parse('2024-04-01 12:00:00')->unix()) ->and($GLOBALS['time'][$state_id]->unix()) ->toBe(CarbonImmutable::parse('2024-04-01 12:00:00')->unix()); diff --git a/tests/Unit/EventStoreFakeTest.php b/tests/Unit/EventStoreFakeTest.php index b1e89331..2e09c160 100644 --- a/tests/Unit/EventStoreFakeTest.php +++ b/tests/Unit/EventStoreFakeTest.php @@ -76,13 +76,13 @@ app()->instance(StoresEvents::class, $store = new EventStoreFake(app(MetadataManager::class))); $state1 = app(StateManager::class)->load( - 1001, type: EventStoreFakeTestState::class, + id: 1001, ); $state2 = app(StateManager::class)->load( - 1002, type: EventStoreFakeTestState::class, + id: 1002, ); // State IDs = 100X, Event IDs = X0Y (X = state, Y = event) From 182f0fae0a44bf69d0d77707b319f7f4f78e500d Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Mon, 7 Jul 2025 22:28:33 -0400 Subject: [PATCH 37/40] wip --- examples/Bank/tests/BankAccountTest.php | 2 +- examples/Counter/tests/StateRehydrationTest.php | 2 +- src/Lifecycle/Broker.php | 2 +- tests/Feature/ReplayClassTest.php | 2 +- tests/Unit/StateReconstitutionTest.php | 2 +- tests/Unit/SupportUuidsTest.php | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/Bank/tests/BankAccountTest.php b/examples/Bank/tests/BankAccountTest.php index 8184f98c..324e995e 100644 --- a/examples/Bank/tests/BankAccountTest.php +++ b/examples/Bank/tests/BankAccountTest.php @@ -99,7 +99,7 @@ // We'll also confirm that the state is correctly loaded without snapshots - app(StateManager::class)->reset(include_storage: true); + app(StateManager::class)->reset(); $account_state = AccountState::load($account->id); expect($account_state->balance_in_cents)->toBe(100_00); diff --git a/examples/Counter/tests/StateRehydrationTest.php b/examples/Counter/tests/StateRehydrationTest.php index 3d6a64f1..6a531df6 100644 --- a/examples/Counter/tests/StateRehydrationTest.php +++ b/examples/Counter/tests/StateRehydrationTest.php @@ -31,7 +31,7 @@ expect(VerbEvent::query()->count())->toBe(1); - app(StateManager::class)->reset(include_storage: true); + app(StateManager::class)->reset(); $state = IncrementCount::fire()->state(); diff --git a/src/Lifecycle/Broker.php b/src/Lifecycle/Broker.php index 764743c7..c6fb46a1 100644 --- a/src/Lifecycle/Broker.php +++ b/src/Lifecycle/Broker.php @@ -94,7 +94,7 @@ public function replay(?callable $beforeEach = null, ?callable $afterEach = null $this->is_replaying = true; try { - $this->states->reset(include_storage: true); + $this->states->reset(); $iteration = 0; diff --git a/tests/Feature/ReplayClassTest.php b/tests/Feature/ReplayClassTest.php index 8a944bb8..66ee4ee1 100644 --- a/tests/Feature/ReplayClassTest.php +++ b/tests/Feature/ReplayClassTest.php @@ -20,7 +20,7 @@ $replay->handle(); - expect($replay->states->cache) + expect($replay->states->cache->values()) ->toHaveCount(1); expect($replay->states->load('1', ReplayClassTestState::class)->count) diff --git a/tests/Unit/StateReconstitutionTest.php b/tests/Unit/StateReconstitutionTest.php index 53face52..de4ca80d 100644 --- a/tests/Unit/StateReconstitutionTest.php +++ b/tests/Unit/StateReconstitutionTest.php @@ -56,7 +56,7 @@ ->and($state2->counter)->toBe(3); Verbs::commit(); - app(StateManager::class)->reset(include_storage: true); + app(StateManager::class)->reset(); $state1 = StateReconstitutionTestState1::load($state1_id); $state2 = StateReconstitutionTestState2::load($state2_id); diff --git a/tests/Unit/SupportUuidsTest.php b/tests/Unit/SupportUuidsTest.php index fbe25631..54ec870a 100644 --- a/tests/Unit/SupportUuidsTest.php +++ b/tests/Unit/SupportUuidsTest.php @@ -49,7 +49,7 @@ state: $state, ); - app(StateManager::class)->reset(include_storage: true); + app(StateManager::class)->reset(); $state = UuidState::load($uuid); From 6dee9893bdaba49443f42be0862858f76ca49277 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Mon, 7 Jul 2025 22:32:14 -0400 Subject: [PATCH 38/40] wip --- src/Lifecycle/Broker.php | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/src/Lifecycle/Broker.php b/src/Lifecycle/Broker.php index c6fb46a1..1ca9e88a 100644 --- a/src/Lifecycle/Broker.php +++ b/src/Lifecycle/Broker.php @@ -34,34 +34,16 @@ public function fireIfValid(Event $event): ?Event public function fire(Event $event): ?Event { if ($this->is_replaying) { - return null; + return null; // FIXME } - // $hooks = Dispatcher::fireHooks()->diff($disabled_hooks); - // $this->dispatcher->triggerHooks($event, $hooks); - - // Lifecycle::for($event, Hooks::fire())->handle(); - - // NOTE: Any changes to how the dispatcher is called here - // should also be applied to the `replay` method - Lifecycle::run( event: $event, phases: Phases::fire(), - // onHandle: fn() => $this->queue ); - // $this->dispatcher->boot($event); - // - // Guards::for($event)->check(); - // - // $this->dispatcher->apply($event); - // - // $this->queue->queue($event); - // - // $this->dispatcher->fired($event); - - // FIXME + // FIXME: This is now in a slightly different execution order + $this->queue->queue($event); if ($this->commit_immediately || $event instanceof CommitsImmediately) { $this->commit(); } @@ -77,10 +59,9 @@ public function commit(): bool return true; } - // FIXME: Only write changes + handle aggregate versioning - - $this->states->writeSnapshots(); - $this->states->prune(); + // FIXME: + // $this->states->writeSnapshots(); + // $this->states->prune(); foreach ($events as $event) { $this->metadata->setLastResults($event, $this->dispatcher->handle($event)); From 8f341a7c21a60ed2510f6c17b293a3be0e31b034 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Mon, 7 Jul 2025 22:58:03 -0400 Subject: [PATCH 39/40] Move things around --- examples/Bank/tests/BankAccountTest.php | 2 +- .../Counter/tests/StateRehydrationTest.php | 2 +- .../Autodiscovery/AppliesToChildState.php | 2 +- .../Autodiscovery/AppliesToState.php | 2 +- .../Autodiscovery/StateDiscoveryAttribute.php | 2 +- src/Attributes/Autodiscovery/StateId.php | 2 +- src/Lifecycle/AggregateStateSummary.php | 2 +- src/Lifecycle/Broker.php | 1 + src/SingletonState.php | 2 +- src/State.php | 2 +- src/State/ReconstitutingStateManager.php | 44 +++++++++++++++++++ src/{Support => State}/StateIdentity.php | 2 +- src/{Lifecycle => State}/StateManager.php | 5 +-- src/Support/EventStateRegistry.php | 2 +- src/Support/Normalization/StateNormalizer.php | 2 +- src/Support/Replay.php | 2 +- src/Support/StateReconstructor.php | 2 +- src/VerbsServiceProvider.php | 2 +- tests/Feature/ReplayClassTest.php | 2 +- tests/Feature/ReplayCommandTest.php | 2 +- tests/Unit/AggregateStateSummaryTest.php | 2 +- tests/Unit/CollectionNormalizerTest.php | 2 +- tests/Unit/EventStoreFakeTest.php | 2 +- tests/Unit/FactoryTest.php | 2 +- tests/Unit/StateReconstitutionTest.php | 2 +- tests/Unit/SupportUuidsTest.php | 2 +- 26 files changed, 70 insertions(+), 26 deletions(-) create mode 100644 src/State/ReconstitutingStateManager.php rename src/{Support => State}/StateIdentity.php (96%) rename src/{Lifecycle => State}/StateManager.php (96%) diff --git a/examples/Bank/tests/BankAccountTest.php b/examples/Bank/tests/BankAccountTest.php index 324e995e..f889db85 100644 --- a/examples/Bank/tests/BankAccountTest.php +++ b/examples/Bank/tests/BankAccountTest.php @@ -11,8 +11,8 @@ use Thunk\Verbs\Examples\Bank\Models\User; use Thunk\Verbs\Examples\Bank\States\AccountState; use Thunk\Verbs\Facades\Verbs; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\Models\VerbEvent; +use Thunk\Verbs\State\StateManager; test('a bank account can be opened and interacted with', function () { Mail::fake(); diff --git a/examples/Counter/tests/StateRehydrationTest.php b/examples/Counter/tests/StateRehydrationTest.php index 6a531df6..925be504 100644 --- a/examples/Counter/tests/StateRehydrationTest.php +++ b/examples/Counter/tests/StateRehydrationTest.php @@ -2,9 +2,9 @@ use Thunk\Verbs\Examples\Counter\Events\IncrementCount; use Thunk\Verbs\Facades\Verbs; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\Models\VerbEvent; use Thunk\Verbs\Models\VerbSnapshot; +use Thunk\Verbs\State\StateManager; beforeEach(function () { Verbs::commitImmediately(); diff --git a/src/Attributes/Autodiscovery/AppliesToChildState.php b/src/Attributes/Autodiscovery/AppliesToChildState.php index ff7c5cde..57ec6bdd 100644 --- a/src/Attributes/Autodiscovery/AppliesToChildState.php +++ b/src/Attributes/Autodiscovery/AppliesToChildState.php @@ -5,8 +5,8 @@ use Attribute; use InvalidArgumentException; use Thunk\Verbs\Event; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; +use Thunk\Verbs\State\StateManager; #[Attribute(Attribute::TARGET_CLASS)] class AppliesToChildState extends StateDiscoveryAttribute diff --git a/src/Attributes/Autodiscovery/AppliesToState.php b/src/Attributes/Autodiscovery/AppliesToState.php index ea8913b3..979d5ec0 100644 --- a/src/Attributes/Autodiscovery/AppliesToState.php +++ b/src/Attributes/Autodiscovery/AppliesToState.php @@ -7,9 +7,9 @@ use Illuminate\Support\Str; use InvalidArgumentException; use Thunk\Verbs\Event; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\SingletonState; use Thunk\Verbs\State; +use Thunk\Verbs\State\StateManager; #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] class AppliesToState extends StateDiscoveryAttribute diff --git a/src/Attributes/Autodiscovery/StateDiscoveryAttribute.php b/src/Attributes/Autodiscovery/StateDiscoveryAttribute.php index 385d97ba..6d628c84 100644 --- a/src/Attributes/Autodiscovery/StateDiscoveryAttribute.php +++ b/src/Attributes/Autodiscovery/StateDiscoveryAttribute.php @@ -6,8 +6,8 @@ use Illuminate\Support\Str; use ReflectionProperty; use Thunk\Verbs\Event; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; +use Thunk\Verbs\State\StateManager; abstract class StateDiscoveryAttribute { diff --git a/src/Attributes/Autodiscovery/StateId.php b/src/Attributes/Autodiscovery/StateId.php index 00bce86a..f3c67af6 100644 --- a/src/Attributes/Autodiscovery/StateId.php +++ b/src/Attributes/Autodiscovery/StateId.php @@ -6,8 +6,8 @@ use Illuminate\Support\Arr; use InvalidArgumentException; use Thunk\Verbs\Event; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; +use Thunk\Verbs\State\StateManager; #[Attribute(Attribute::TARGET_PROPERTY)] class StateId extends StateDiscoveryAttribute diff --git a/src/Lifecycle/AggregateStateSummary.php b/src/Lifecycle/AggregateStateSummary.php index b96d0198..ca2dbf32 100644 --- a/src/Lifecycle/AggregateStateSummary.php +++ b/src/Lifecycle/AggregateStateSummary.php @@ -6,7 +6,7 @@ use Illuminate\Support\Collection; use Thunk\Verbs\Models\VerbStateEvent; use Thunk\Verbs\State; -use Thunk\Verbs\Support\StateIdentity; +use Thunk\Verbs\State\StateIdentity; class AggregateStateSummary { diff --git a/src/Lifecycle/Broker.php b/src/Lifecycle/Broker.php index 1ca9e88a..489f19bc 100644 --- a/src/Lifecycle/Broker.php +++ b/src/Lifecycle/Broker.php @@ -8,6 +8,7 @@ use Thunk\Verbs\Event; use Thunk\Verbs\Exceptions\EventNotValid; use Thunk\Verbs\Lifecycle\Queue as EventQueue; +use Thunk\Verbs\State\StateManager; class Broker implements BrokersEvents { diff --git a/src/SingletonState.php b/src/SingletonState.php index cc28eb24..718d0508 100644 --- a/src/SingletonState.php +++ b/src/SingletonState.php @@ -4,7 +4,7 @@ use BadMethodCallException; use RuntimeException; -use Thunk\Verbs\Lifecycle\StateManager; +use Thunk\Verbs\State\StateManager; use Thunk\Verbs\Support\StateCollection; abstract class SingletonState extends State diff --git a/src/State.php b/src/State.php index 0907ee0e..c4e946dc 100644 --- a/src/State.php +++ b/src/State.php @@ -10,7 +10,7 @@ use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Contracts\StoresEvents; use Thunk\Verbs\Exceptions\StateNotFoundException; -use Thunk\Verbs\Lifecycle\StateManager; +use Thunk\Verbs\State\StateManager; use Thunk\Verbs\Support\Serializer; use Thunk\Verbs\Support\StateCollection; diff --git a/src/State/ReconstitutingStateManager.php b/src/State/ReconstitutingStateManager.php new file mode 100644 index 00000000..7e489972 --- /dev/null +++ b/src/State/ReconstitutingStateManager.php @@ -0,0 +1,44 @@ + $type - * @return TState */ public function make(string $type, Bits|UuidInterface|AbstractUid|int|string|null $id): State { @@ -53,7 +52,7 @@ public function make(string $type, Bits|UuidInterface|AbstractUid|int|string|nul // State::__construct() auto-registers the state with the StateManager, // so we need to skip the constructor until we've already set the ID. - /** @var TState $state */ + /** @var State $state */ $state = (new ReflectionClass($type))->newInstanceWithoutConstructor(); $state->id = Id::tryFrom($id) ?? snowflake_id(); $state->__construct(); diff --git a/src/Support/EventStateRegistry.php b/src/Support/EventStateRegistry.php index 692a4bb5..53cf4b09 100644 --- a/src/Support/EventStateRegistry.php +++ b/src/Support/EventStateRegistry.php @@ -14,8 +14,8 @@ use ReflectionUnionType; use Thunk\Verbs\Attributes\Autodiscovery\StateDiscoveryAttribute; use Thunk\Verbs\Event; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; +use Thunk\Verbs\State\StateManager; use WeakMap; class EventStateRegistry diff --git a/src/Support/Normalization/StateNormalizer.php b/src/Support/Normalization/StateNormalizer.php index d64d53da..f4be532b 100644 --- a/src/Support/Normalization/StateNormalizer.php +++ b/src/Support/Normalization/StateNormalizer.php @@ -5,8 +5,8 @@ use InvalidArgumentException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; +use Thunk\Verbs\State\StateManager; use Thunk\Verbs\Support\Serializer; class StateNormalizer implements DenormalizerInterface, NormalizerInterface diff --git a/src/Support/Replay.php b/src/Support/Replay.php index d34feb92..9f861d33 100644 --- a/src/Support/Replay.php +++ b/src/Support/Replay.php @@ -5,7 +5,7 @@ use Illuminate\Support\Enumerable; use Thunk\Verbs\Lifecycle\Lifecycle; use Thunk\Verbs\Lifecycle\Phases; -use Thunk\Verbs\Lifecycle\StateManager; +use Thunk\Verbs\State\StateManager; class Replay { diff --git a/src/Support/StateReconstructor.php b/src/Support/StateReconstructor.php index 0c48ec84..c20dcd58 100644 --- a/src/Support/StateReconstructor.php +++ b/src/Support/StateReconstructor.php @@ -6,8 +6,8 @@ use Thunk\Verbs\Contracts\StoresEvents; use Thunk\Verbs\Lifecycle\Dispatcher; use Thunk\Verbs\Lifecycle\NullSnapshotStore; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; +use Thunk\Verbs\State\StateManager; class StateReconstructor { diff --git a/src/VerbsServiceProvider.php b/src/VerbsServiceProvider.php index ed15c243..1e38206d 100644 --- a/src/VerbsServiceProvider.php +++ b/src/VerbsServiceProvider.php @@ -32,9 +32,9 @@ use Thunk\Verbs\Lifecycle\MetadataManager; use Thunk\Verbs\Lifecycle\Queue as EventQueue; use Thunk\Verbs\Lifecycle\SnapshotStore; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\Livewire\SupportVerbs; use Thunk\Verbs\State\Cache\MultiCache; +use Thunk\Verbs\State\StateManager; use Thunk\Verbs\Support\EventStateRegistry; use Thunk\Verbs\Support\IdManager; use Thunk\Verbs\Support\Serializer; diff --git a/tests/Feature/ReplayClassTest.php b/tests/Feature/ReplayClassTest.php index 66ee4ee1..11846b18 100644 --- a/tests/Feature/ReplayClassTest.php +++ b/tests/Feature/ReplayClassTest.php @@ -3,9 +3,9 @@ use Thunk\Verbs\Attributes\Autodiscovery\StateId; use Thunk\Verbs\Event; use Thunk\Verbs\Lifecycle\Phases; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; use Thunk\Verbs\State\Cache\InMemoryCache; +use Thunk\Verbs\State\StateManager; use Thunk\Verbs\Support\Replay; it('can rebuild state from events', function () { diff --git a/tests/Feature/ReplayCommandTest.php b/tests/Feature/ReplayCommandTest.php index 69fddbfb..d8d2db5f 100644 --- a/tests/Feature/ReplayCommandTest.php +++ b/tests/Feature/ReplayCommandTest.php @@ -7,9 +7,9 @@ use Thunk\Verbs\Event; use Thunk\Verbs\Facades\Id; use Thunk\Verbs\Facades\Verbs; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\Models\VerbSnapshot; use Thunk\Verbs\State; +use Thunk\Verbs\State\StateManager; beforeEach(function () { $GLOBALS['replay_test_counts'] = []; diff --git a/tests/Unit/AggregateStateSummaryTest.php b/tests/Unit/AggregateStateSummaryTest.php index d12a45b3..d1abbaac 100644 --- a/tests/Unit/AggregateStateSummaryTest.php +++ b/tests/Unit/AggregateStateSummaryTest.php @@ -3,7 +3,7 @@ use Thunk\Verbs\Lifecycle\AggregateStateSummary; use Thunk\Verbs\Models\VerbStateEvent; use Thunk\Verbs\State; -use Thunk\Verbs\Support\StateIdentity; +use Thunk\Verbs\State\StateIdentity; test('it finds the correct states and events for one state', function () { $matching_state_types = [ diff --git a/tests/Unit/CollectionNormalizerTest.php b/tests/Unit/CollectionNormalizerTest.php index 4b769faa..cd4a733b 100644 --- a/tests/Unit/CollectionNormalizerTest.php +++ b/tests/Unit/CollectionNormalizerTest.php @@ -7,9 +7,9 @@ use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; use Symfony\Component\Serializer\Serializer as SymfonySerializer; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\SerializedByVerbs; use Thunk\Verbs\State; +use Thunk\Verbs\State\StateManager; use Thunk\Verbs\Support\Normalization\CarbonNormalizer; use Thunk\Verbs\Support\Normalization\CollectionNormalizer; use Thunk\Verbs\Support\Normalization\NormalizeToPropertiesAndClassName; diff --git a/tests/Unit/EventStoreFakeTest.php b/tests/Unit/EventStoreFakeTest.php index 2e09c160..3c36b468 100644 --- a/tests/Unit/EventStoreFakeTest.php +++ b/tests/Unit/EventStoreFakeTest.php @@ -4,8 +4,8 @@ use Thunk\Verbs\Contracts\StoresEvents; use Thunk\Verbs\Event; use Thunk\Verbs\Lifecycle\MetadataManager; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; +use Thunk\Verbs\State\StateManager; use Thunk\Verbs\Testing\EventStoreFake; it('performs assertions', function () { diff --git a/tests/Unit/FactoryTest.php b/tests/Unit/FactoryTest.php index c846be11..a4919603 100644 --- a/tests/Unit/FactoryTest.php +++ b/tests/Unit/FactoryTest.php @@ -1,9 +1,9 @@ Date: Tue, 8 Jul 2025 09:32:35 -0400 Subject: [PATCH 40/40] ugh --- src/Lifecycle/AggregateStateSummary.php | 11 +++++- src/State/ReconstitutingStateManager.php | 50 ++++++++++++++++++------ 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/Lifecycle/AggregateStateSummary.php b/src/Lifecycle/AggregateStateSummary.php index ca2dbf32..a41f99b6 100644 --- a/src/Lifecycle/AggregateStateSummary.php +++ b/src/Lifecycle/AggregateStateSummary.php @@ -4,6 +4,8 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; +use Illuminate\Support\Enumerable; +use Thunk\Verbs\Contracts\StoresEvents; use Thunk\Verbs\Models\VerbStateEvent; use Thunk\Verbs\State; use Thunk\Verbs\State\StateIdentity; @@ -33,6 +35,11 @@ public function __construct( public Collection $related_states = new Collection, ) {} + public function events(): Enumerable + { + return app(StoresEvents::class)->get($this->related_event_ids); + } + protected function discover(): static { $this->discoverNewEventIds(); @@ -53,7 +60,7 @@ protected function discoverNewEventIds(): bool ->select('event_id') ->whereNotIn('event_id', $this->related_event_ids) ->where(fn (Builder $query) => $this->related_states->each( - fn ($state) => $query->orWhere(fn (Builder $query) => $this->addConstraint($state, $query))) + fn ($state) => $query->orWhere(fn (Builder $query) => $this->addConstraint($state, $query))), ) ->toBase() ->pluck('event_id'); @@ -71,7 +78,7 @@ protected function discoverNewStates(): bool ->select(['state_id', 'state_type']) ->whereIn('event_id', $this->related_event_ids) ->where(fn (Builder $query) => $this->related_states->each( - fn ($state) => $query->whereNot(fn (Builder $query) => $this->addConstraint($state, $query))) + fn ($state) => $query->whereNot(fn (Builder $query) => $this->addConstraint($state, $query))), ) ->toBase() ->chunkMap(StateIdentity::from(...)); diff --git a/src/State/ReconstitutingStateManager.php b/src/State/ReconstitutingStateManager.php index 7e489972..a5f5b40c 100644 --- a/src/State/ReconstitutingStateManager.php +++ b/src/State/ReconstitutingStateManager.php @@ -3,13 +3,19 @@ namespace Thunk\Verbs\State; use Glhd\Bits\Bits; +use Illuminate\Support\Facades\DB; use Ramsey\Uuid\UuidInterface; use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Contracts\StoresEvents; use Thunk\Verbs\Lifecycle\AggregateStateSummary; +use Thunk\Verbs\Lifecycle\Phase; +use Thunk\Verbs\Lifecycle\Phases; +use Thunk\Verbs\Models\VerbStateEvent; use Thunk\Verbs\State; use Thunk\Verbs\State\Cache\Contracts\ReadableCache; use Thunk\Verbs\State\Cache\Contracts\WritableCache; +use Thunk\Verbs\State\Cache\InMemoryCache; +use Thunk\Verbs\Support\Replay; use Thunk\Verbs\Support\StateCollection; class ReconstitutingStateManager extends StateManager @@ -23,22 +29,40 @@ public function __construct( public function load(string $type, Bits|UuidInterface|AbstractUid|iterable|int|string|null $id): StateCollection|State { - $state = parent::load($type, $id); + $states = parent::load($type, $id); - $summary = AggregateStateSummary::summarize($state); + if ($states instanceof State) { + $states = new StateCollection([$states]); + } - /* - * Scenarios we care about: - * - There are events that fired since these state(s) were loaded - * - We have an out-of-date snapshot - * - This state relies on other state that's out of date - */ + // If there have been no events since ANY of these states' last_event_id, we can just return + VerbStateEvent::query() + ->toBase() + ->select(['state_type', 'state_id', DB::raw('max(event_id) as max_event_id')]) + ->where(function ($query) use ($states) { + foreach ($states as $state) { + $query->orWhere(function ($query) use ($state) { + $query->where('state_type', $state::class); + $query->where('state_id', $state->id); + }); + } + }) + ->each(function ($row) { + // TODO: Compare to states + }); - // FIXME: - // Figure out if state(s) is up-to-date - // If not, set up a Replay and run it, then grab the states from - // that replay and push them into this. + $summary = AggregateStateSummary::summarize($states); - return $state; + $replay = new Replay( + states: new StateManager(new InMemoryCache), // FIXME: Use states from summary + events: $summary->events(), + phases: new Phases(Phase::Apply), + ); + + $replay->handle(); + + // FIXME: Get all states loaded during replay and add them to our cache + + // FIXME return $state; } }