diff --git a/app/database/Migrations/20260327.000000_0_0_default_change_events_add_is_pinned.php b/app/database/Migrations/20260327.000000_0_0_default_change_events_add_is_pinned.php new file mode 100644 index 00000000..ddcf412f --- /dev/null +++ b/app/database/Migrations/20260327.000000_0_0_default_change_events_add_is_pinned.php @@ -0,0 +1,26 @@ +table('events') + ->addColumn('is_pinned', 'boolean', ['nullable' => false, 'defaultValue' => false]) + ->update(); + } + + public function down(): void + { + $this->table('events') + ->dropColumn('is_pinned') + ->update(); + } +} diff --git a/app/modules/Events/Application/Broadcasting/EventWasReceivedMapper.php b/app/modules/Events/Application/Broadcasting/EventWasReceivedMapper.php index e0506174..b04e3d33 100644 --- a/app/modules/Events/Application/Broadcasting/EventWasReceivedMapper.php +++ b/app/modules/Events/Application/Broadcasting/EventWasReceivedMapper.php @@ -37,6 +37,7 @@ public function toBroadcast(object $event): BroadcastEvent type: $event->event->getType(), payload: $event->event->getPayload(), ), + 'is_pinned' => $event->event->isPinned(), ], ); } diff --git a/app/modules/Events/Domain/Event.php b/app/modules/Events/Domain/Event.php index b80df75b..1ec61b61 100644 --- a/app/modules/Events/Domain/Event.php +++ b/app/modules/Events/Domain/Event.php @@ -29,6 +29,7 @@ class Event public const PAYLOAD = 'payload'; public const TIMESTAMP = 'timestamp'; public const PROJECT = 'project'; + public const IS_PINNED = 'is_pinned'; /** @internal */ public function __construct( @@ -42,6 +43,8 @@ public function __construct( private Timestamp $timestamp, #[Column(type: 'string', name: self::PROJECT, nullable: true, typecast: Key::class)] private ?Key $project = null, + #[Column(type: 'boolean', name: self::IS_PINNED, default: false)] + private bool $isPinned = false, ) {} public function getUuid(): Uuid @@ -73,4 +76,19 @@ public function getProject(): ?Key { return $this->project; } + + public function isPinned(): bool + { + return $this->isPinned; + } + + public function pin(): void + { + $this->isPinned = true; + } + + public function unpin(): void + { + $this->isPinned = false; + } } diff --git a/app/modules/Events/Domain/EventRepositoryInterface.php b/app/modules/Events/Domain/EventRepositoryInterface.php index e40eaae7..908ce264 100644 --- a/app/modules/Events/Domain/EventRepositoryInterface.php +++ b/app/modules/Events/Domain/EventRepositoryInterface.php @@ -21,4 +21,8 @@ public function store(Event $event): bool; public function deleteAll(array $scope = []): void; public function deleteByPK(string $uuid): bool; + + public function pin(string $uuid): bool; + + public function unpin(string $uuid): bool; } diff --git a/app/modules/Events/Integration/CycleOrm/EventRepository.php b/app/modules/Events/Integration/CycleOrm/EventRepository.php index 6909e693..6d9b9b16 100644 --- a/app/modules/Events/Integration/CycleOrm/EventRepository.php +++ b/app/modules/Events/Integration/CycleOrm/EventRepository.php @@ -34,6 +34,7 @@ public function store(Event $event): bool Event::PAYLOAD => $payload, Event::TIMESTAMP => (string) $event->getTimestamp(), Event::PROJECT => $event->getProject() !== null ? (string) $event->getProject() : null, + Event::IS_PINNED => $event->isPinned(), ])->run(); } catch (\Throwable) { $this->db->update(Event::TABLE_NAME) @@ -47,16 +48,35 @@ public function store(Event $event): bool public function deleteAll(array $scope = []): void { - $this->db + $query = $this->db ->delete(Event::TABLE_NAME) - ->where($this->buildScope($scope)) - ->run(); + ->where($this->buildScope($scope)); + + $query->where(Event::IS_PINNED, false); + $query->run(); } public function deleteByPK(string $uuid): bool { return $this->db->delete(Event::TABLE_NAME) ->where(Event::UUID, $uuid) + ->where(Event::IS_PINNED, false) + ->run() > 0; + } + + public function pin(string $uuid): bool + { + return $this->db->update(Event::TABLE_NAME) + ->where(Event::UUID, $uuid) + ->values([Event::IS_PINNED => true]) + ->run() > 0; + } + + public function unpin(string $uuid): bool + { + return $this->db->update(Event::TABLE_NAME) + ->where(Event::UUID, $uuid) + ->values([Event::IS_PINNED => false]) ->run() > 0; } diff --git a/app/modules/Events/Interfaces/Commands/StoreEventHandler.php b/app/modules/Events/Interfaces/Commands/StoreEventHandler.php index 74f6d413..1321f166 100644 --- a/app/modules/Events/Interfaces/Commands/StoreEventHandler.php +++ b/app/modules/Events/Interfaces/Commands/StoreEventHandler.php @@ -4,6 +4,7 @@ namespace Modules\Events\Interfaces\Commands; +use App\Application\Commands\CreateProject; use App\Application\Commands\FindProjectByKey; use App\Application\Commands\HandleReceivedEvent; use App\Application\Domain\ValueObjects\Json; @@ -24,16 +25,26 @@ public function __construct( private EventDispatcherInterface $dispatcher, private EventRepositoryInterface $events, private QueryBusInterface $queryBus, + private \Spiral\Cqrs\CommandBusInterface $commandBus, private EventMetrics $metrics, ) {} #[CommandHandler] public function handle(HandleReceivedEvent $command): void { - $project = null; - // If the project is not null, we will find the project by key - if ($command->project !== null) { - $project = $this->queryBus->ask(new FindProjectByKey($command->project)); + $projectKey = $command->project ?? Project::DEFAULT_KEY; + + $project = $this->queryBus->ask(new FindProjectByKey($projectKey)); + + if ($project === null) { + try { + $project = $this->commandBus->dispatch( + new CreateProject(key: $projectKey, name: $projectKey), + ); + } catch (\Throwable) { + // Race condition: project was created between check and create + $project = $this->queryBus->ask(new FindProjectByKey($projectKey)); + } } $this->events->store( @@ -42,7 +53,6 @@ public function handle(HandleReceivedEvent $command): void type: $command->type, payload: new Json($command->payload), timestamp: Timestamp::create(), - // todo: use better option for default project project: $project?->getKey() ?? Key::create(Project::DEFAULT_KEY), ), ); diff --git a/app/modules/Events/Interfaces/Http/Controllers/PinEventAction.php b/app/modules/Events/Interfaces/Http/Controllers/PinEventAction.php new file mode 100644 index 00000000..a334af9d --- /dev/null +++ b/app/modules/Events/Interfaces/Http/Controllers/PinEventAction.php @@ -0,0 +1,37 @@ +/pin', name: 'event.pin', methods: ['POST'], group: 'api')] + public function pin( + EventRepositoryInterface $events, + Uuid $uuid, + ): array { + if (!$events->pin((string) $uuid)) { + throw new NotFoundException('Event not found'); + } + + return ['status' => 'pinned']; + } + + #[Route(route: 'event//pin', name: 'event.unpin', methods: ['DELETE'], group: 'api')] + public function unpin( + EventRepositoryInterface $events, + Uuid $uuid, + ): array { + if (!$events->unpin((string) $uuid)) { + throw new NotFoundException('Event not found'); + } + + return ['status' => 'unpinned']; + } +} diff --git a/app/modules/Events/Interfaces/Http/Resources/EventPreviewResource.php b/app/modules/Events/Interfaces/Http/Resources/EventPreviewResource.php index 02031919..4d651a01 100644 --- a/app/modules/Events/Interfaces/Http/Resources/EventPreviewResource.php +++ b/app/modules/Events/Interfaces/Http/Resources/EventPreviewResource.php @@ -47,6 +47,7 @@ protected function mapData(): array|\JsonSerializable type: $this->data->getType(), payload: $this->data->getPayload(), ) ?? '', + 'is_pinned' => $this->data->isPinned(), ]; } } diff --git a/app/modules/Profiler/Application/Query/CompareProfiles.php b/app/modules/Profiler/Application/Query/CompareProfiles.php new file mode 100644 index 00000000..ea4786d2 --- /dev/null +++ b/app/modules/Profiler/Application/Query/CompareProfiles.php @@ -0,0 +1,17 @@ +ask( + new CompareProfiles( + baseProfileUuid: new Uuid($request->base), + compareProfileUuid: new Uuid($request->compare), + ), + ); + } catch (EntityNotFoundException $e) { + throw new NotFoundException($e->getMessage()); + } + } +} diff --git a/app/modules/Profiler/Interfaces/Http/Controllers/ShowSummaryAction.php b/app/modules/Profiler/Interfaces/Http/Controllers/ShowSummaryAction.php new file mode 100644 index 00000000..f64b7025 --- /dev/null +++ b/app/modules/Profiler/Interfaces/Http/Controllers/ShowSummaryAction.php @@ -0,0 +1,27 @@ +/summary', name: 'profiler.show.summary', methods: ['GET'], group: 'api')] + public function __invoke( + QueryBusInterface $bus, + Uuid $uuid, + ): array { + try { + return $bus->ask(new FindProfileSummaryByUuid(profileUuid: $uuid)); + } catch (EntityNotFoundException $e) { + throw new NotFoundException($e->getMessage()); + } + } +} diff --git a/app/modules/Profiler/Interfaces/Http/Request/CompareProfilesRequest.php b/app/modules/Profiler/Interfaces/Http/Request/CompareProfilesRequest.php new file mode 100644 index 00000000..5f7d6d6d --- /dev/null +++ b/app/modules/Profiler/Interfaces/Http/Request/CompareProfilesRequest.php @@ -0,0 +1,34 @@ + [ + ['notEmpty'], + ['string::length', 36, 36], + ], + 'compare' => [ + ['notEmpty'], + ['string::length', 36, 36], + ], + ]); + } +} diff --git a/app/modules/Profiler/Interfaces/Queries/CompareProfilesHandler.php b/app/modules/Profiler/Interfaces/Queries/CompareProfilesHandler.php new file mode 100644 index 00000000..5e2f021d --- /dev/null +++ b/app/modules/Profiler/Interfaces/Queries/CompareProfilesHandler.php @@ -0,0 +1,105 @@ +aggregateFunctions($query->baseProfileUuid); + $compareFunctions = $this->aggregateFunctions($query->compareProfileUuid); + + $allFunctionNames = \array_unique(\array_merge( + \array_keys($baseFunctions), + \array_keys($compareFunctions), + )); + + $metrics = ['cpu', 'wt', 'ct', 'mu', 'pmu', 'excl_cpu', 'excl_wt', 'excl_ct', 'excl_mu', 'excl_pmu']; + $diff = []; + + foreach ($allFunctionNames as $fn) { + $base = $baseFunctions[$fn] ?? null; + $compare = $compareFunctions[$fn] ?? null; + + $row = ['function' => $fn]; + + foreach ($metrics as $metric) { + $baseVal = $base[$metric] ?? 0; + $compareVal = $compare[$metric] ?? 0; + $row['base_' . $metric] = $baseVal; + $row['compare_' . $metric] = $compareVal; + $row['diff_' . $metric] = $compareVal - $baseVal; + } + + $diff[] = $row; + } + + // Sort by absolute diff in exclusive wall time descending + \usort($diff, static fn(array $a, array $b) => \abs($b['diff_excl_wt']) <=> \abs($a['diff_excl_wt'])); + + return [ + 'functions' => \array_slice($diff, 0, $query->limit), + ]; + } + + private function aggregateFunctions(mixed $profileUuid): array + { + $profile = $this->orm->getRepository(Profile::class)->findByPK($profileUuid); + $functions = []; + $metrics = ['cpu', 'ct', 'wt', 'mu', 'pmu']; + + /** @var Edge[] $edges */ + $edges = $profile->edges; + + foreach ($edges as $edge) { + $callee = $edge->getCallee(); + if (!isset($functions[$callee])) { + $functions[$callee] = []; + foreach ($metrics as $metric) { + $functions[$callee][$metric] = 0; + } + } + foreach ($metrics as $metric) { + $functions[$callee][$metric] += $edge->getCost()->{$metric}; + } + } + + // Exclusive metrics + foreach (\array_keys($functions) as $fn) { + foreach ($metrics as $metric) { + $functions[$fn]['excl_' . $metric] = $functions[$fn][$metric]; + } + } + + foreach ($edges as $edge) { + if (!$edge->getCaller()) { + continue; + } + foreach ($metrics as $metric) { + $field = 'excl_' . $metric; + if (!isset($functions[$edge->getCaller()][$field])) { + continue; + } + $functions[$edge->getCaller()][$field] -= $edge->getCost()->{$metric}; + if ($functions[$edge->getCaller()][$field] < 0) { + $functions[$edge->getCaller()][$field] = 0; + } + } + } + + return $functions; + } +} diff --git a/app/modules/Profiler/Interfaces/Queries/FindProfileSummaryByUuidHandler.php b/app/modules/Profiler/Interfaces/Queries/FindProfileSummaryByUuidHandler.php new file mode 100644 index 00000000..5a521148 --- /dev/null +++ b/app/modules/Profiler/Interfaces/Queries/FindProfileSummaryByUuidHandler.php @@ -0,0 +1,118 @@ +orm->getRepository(Profile::class)->findByPK($query->profileUuid); + + $functions = []; + $metrics = ['cpu', 'ct', 'wt', 'mu', 'pmu']; + + /** @var Edge[] $edges */ + $edges = $profile->edges; + + // Aggregate inclusive metrics per function + foreach ($edges as $edge) { + $callee = $edge->getCallee(); + + if (!isset($functions[$callee])) { + $functions[$callee] = ['function' => $callee]; + foreach ($metrics as $metric) { + $functions[$callee][$metric] = 0; + } + } + + foreach ($metrics as $metric) { + $functions[$callee][$metric] += $edge->getCost()->{$metric}; + } + } + + // Overall totals from main() + $overallTotals = []; + foreach ($metrics as $metric) { + $overallTotals[$metric] = $functions['main()'][$metric] ?? 0; + } + $overallTotals['ct'] = 0; + foreach ($functions as $m) { + $overallTotals['ct'] += $m['ct']; + } + + // Initialize exclusive metrics + foreach (\array_keys($functions) as $function) { + foreach ($metrics as $metric) { + $functions[$function]['excl_' . $metric] = $functions[$function][$metric]; + } + } + + // Subtract children's inclusive from parent's exclusive + foreach ($edges as $edge) { + if (!$edge->getCaller()) { + continue; + } + + foreach ($metrics as $metric) { + $field = 'excl_' . $metric; + if (!isset($functions[$edge->getCaller()][$field])) { + continue; + } + $functions[$edge->getCaller()][$field] -= $edge->getCost()->{$metric}; + if ($functions[$edge->getCaller()][$field] < 0) { + $functions[$edge->getCaller()][$field] = 0; + } + } + } + + $fnList = \array_values($functions); + + // Slowest function by exclusive wall time + \usort($fnList, static fn(array $a, array $b) => ($b['excl_wt'] ?? 0) <=> ($a['excl_wt'] ?? 0)); + $slowest = $fnList[0] ?? null; + + // Memory hotspot by exclusive memory usage + \usort($fnList, static fn(array $a, array $b) => ($b['excl_mu'] ?? 0) <=> ($a['excl_mu'] ?? 0)); + $memoryHotspot = $fnList[0] ?? null; + + // Most called function (exclude main) + $fnListNoMain = \array_filter($fnList, static fn(array $f) => $f['function'] !== 'main()'); + \usort($fnListNoMain, static fn(array $a, array $b) => ($b['ct'] ?? 0) <=> ($a['ct'] ?? 0)); + $mostCalled = \array_values($fnListNoMain)[0] ?? null; + + return [ + 'overall_totals' => $overallTotals, + 'slowest_function' => $slowest ? [ + 'function' => $slowest['function'], + 'excl_wt' => $slowest['excl_wt'], + 'p_excl_wt' => $overallTotals['wt'] > 0 + ? \round($slowest['excl_wt'] / $overallTotals['wt'] * 100, 1) + : 0, + ] : null, + 'memory_hotspot' => $memoryHotspot ? [ + 'function' => $memoryHotspot['function'], + 'excl_mu' => $memoryHotspot['excl_mu'], + 'p_excl_mu' => $overallTotals['mu'] > 0 + ? \round($memoryHotspot['excl_mu'] / $overallTotals['mu'] * 100, 1) + : 0, + ] : null, + 'most_called' => $mostCalled ? [ + 'function' => $mostCalled['function'], + 'ct' => $mostCalled['ct'], + ] : null, + ]; + } +} diff --git a/tests/Unit/Modules/Events/Domain/CacheEventRepositoryTest.php b/tests/Unit/Modules/Events/Domain/CacheEventRepositoryTest.php index a3ffa2b8..5ffe8853 100644 --- a/tests/Unit/Modules/Events/Domain/CacheEventRepositoryTest.php +++ b/tests/Unit/Modules/Events/Domain/CacheEventRepositoryTest.php @@ -134,4 +134,52 @@ public function testDeleteByTypeAndUuids(): void $this->assertSame((string) $event2->getUuid(), (string) $result[1]->getUuid()); $this->assertSame((string) $event3->getUuid(), (string) $result[2]->getUuid()); } + + public function testPinEvent(): void + { + $event = $this->createEvent(); + + $this->assertTrue($this->repository->pin((string) $event->getUuid())); + + $this->cleanIdentityMap(); + $found = $this->repository->findByPK($event->getUuid()); + $this->assertTrue($found->isPinned()); + } + + public function testUnpinEvent(): void + { + $event = $this->createEvent(); + $this->repository->pin((string) $event->getUuid()); + + $this->assertTrue($this->repository->unpin((string) $event->getUuid())); + + $this->cleanIdentityMap(); + $found = $this->repository->findByPK($event->getUuid()); + $this->assertFalse($found->isPinned()); + } + + public function testDeleteAllSkipsPinnedEvents(): void + { + $event1 = $this->createEvent(); + $event2 = $this->createEvent(); + $this->repository->pin((string) $event1->getUuid()); + + $this->repository->deleteAll([]); + + $this->assertCount(1, \iterator_to_array($this->repository->findAll())); + + $this->cleanIdentityMap(); + $found = $this->repository->findByPK($event1->getUuid()); + $this->assertNotNull($found); + $this->assertTrue($found->isPinned()); + } + + public function testDeleteByPKSkipsPinnedEvent(): void + { + $event = $this->createEvent(); + $this->repository->pin((string) $event->getUuid()); + + $this->assertFalse($this->repository->deleteByPK((string) $event->getUuid())); + $this->assertCount(1, \iterator_to_array($this->repository->findAll())); + } }