fix: remove static keyword from Saga getName() methods

AbstractSaga::getName() is non-static, but child classes
(UserOnboardingSaga, OrderFulfillmentSaga) tried to override it as static.

Also fixed ConsoleCommand usage:
- ConsoleCommand is an attribute, not an interface
- SagaCommands and ReplayCommands now use #[ConsoleCommand] attributes
- All command methods properly annotated
This commit is contained in:
2025-10-05 23:19:47 +02:00
parent 33c1afe208
commit caa85db796
4 changed files with 1112 additions and 0 deletions

View File

@@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
namespace App\Framework\EventSourcing\Replay;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ExitCode;
use App\Framework\EventSourcing\AggregateId;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Event Replay Console Commands
*
* Manage event replay and time travel debugging via CLI
*/
final readonly class ReplayCommands
{
public function __construct(
private EventReplayer $replayer,
private TimeTravelDebugger $timeTravelDebugger,
private EventHistoryVisualizer $visualizer,
private ProjectionRebuilder $projectionRebuilder
) {}
/**
* Replay events for aggregate
*
* Usage: php console.php replay:events <aggregate-id> [strategy]
*/
#[ConsoleCommand('replay:events', 'Replay events for aggregate')]
public function events(ConsoleInput $input): int
{
$aggregateIdStr = $input->getArgument('aggregate-id');
$strategyType = $input->getArgument('strategy') ?? 'full';
if (!$aggregateIdStr) {
echo "Error: Aggregate ID required\n";
echo "Usage: replay:events <aggregate-id> [strategy]\n";
echo "Strategies: full, from-version, time-range, event-type\n";
return ExitCode::ERROR;
}
$aggregateId = $this->createAggregateId($aggregateIdStr);
$strategy = $this->createStrategy($strategyType, $input);
echo "Replaying events for aggregate: {$aggregateIdStr}\n";
echo "Strategy: {$strategy->getDescription()}\n\n";
$count = 0;
$this->replayer->replayWithCallback(
aggregateId: $aggregateId,
strategy: $strategy,
callback: function($envelope) use (&$count) {
$count++;
echo "[{$count}] Version {$envelope->version}: ";
echo class_basename(get_class($envelope->event));
echo " @ {$envelope->timestamp->format('Y-m-d H:i:s')}\n";
}
);
echo "\nTotal events replayed: {$count}\n";
return ExitCode::SUCCESS;
}
/**
* Time travel to specific point
*
* Usage: php console.php replay:time-travel <aggregate-id> <timestamp>
*/
#[ConsoleCommand('replay:time-travel', 'Time travel to specific point')]
public function timeTravel(ConsoleInput $input): int
{
$aggregateIdStr = $input->getArgument('aggregate-id');
$timestampStr = $input->getArgument('timestamp');
if (!$aggregateIdStr || !$timestampStr) {
echo "Error: Aggregate ID and timestamp required\n";
echo "Usage: replay:time-travel <aggregate-id> <timestamp>\n";
echo "Example: replay:time-travel order-123 '2024-01-15 14:30:00'\n";
return ExitCode::ERROR;
}
$aggregateId = $this->createAggregateId($aggregateIdStr);
$targetTime = Timestamp::fromString($timestampStr);
echo "Time traveling to: {$targetTime->format('Y-m-d H:i:s')}\n\n";
// Note: Would need aggregate factory
echo "State reconstruction would happen here\n";
echo "(Requires aggregate factory implementation)\n";
return ExitCode::SUCCESS;
}
/**
* Show event history timeline
*
* Usage: php console.php replay:timeline <aggregate-id>
*/
#[ConsoleCommand('replay:timeline', 'Show event history timeline')]
public function timeline(ConsoleInput $input): int
{
$aggregateIdStr = $input->getArgument('aggregate-id');
if (!$aggregateIdStr) {
echo "Error: Aggregate ID required\n";
return ExitCode::ERROR;
}
$aggregateId = $this->createAggregateId($aggregateIdStr);
$result = $this->visualizer->generateTimeline($aggregateId);
echo "Event Timeline for: {$aggregateIdStr}\n";
echo str_repeat('=', 80) . "\n\n";
// Statistics
$stats = $result['statistics'];
echo "Statistics:\n";
echo " Total Events: {$stats['total_events']}\n";
echo " Time Span: {$stats['time_span_hours']} hours\n";
echo " Avg Events/Day: {$stats['average_events_per_day']}\n";
echo " Unique Event Types: {$stats['unique_event_types']}\n\n";
// Timeline
echo "Timeline:\n";
echo str_repeat('-', 80) . "\n";
foreach ($result['timeline'] as $entry) {
printf(
"v%-5d %s %s\n",
$entry['version'],
$entry['timestamp'],
$entry['event_type']
);
}
return ExitCode::SUCCESS;
}
/**
* Show event statistics
*
* Usage: php console.php replay:stats <aggregate-id>
*/
#[ConsoleCommand('replay:stats', 'Show event statistics')]
public function stats(ConsoleInput $input): int
{
$aggregateIdStr = $input->getArgument('aggregate-id');
if (!$aggregateIdStr) {
echo "Error: Aggregate ID required\n";
return ExitCode::ERROR;
}
$aggregateId = $this->createAggregateId($aggregateIdStr);
$summary = $this->visualizer->getStreamSummary($aggregateId);
echo "Event Stream Summary\n";
echo str_repeat('=', 80) . "\n\n";
echo "Aggregate ID: {$summary['aggregate_id']}\n";
echo "Total Events: {$summary['total_events']}\n\n";
echo "First Event:\n";
echo " Version: {$summary['first_event']['version']}\n";
echo " Type: {$summary['first_event']['type']}\n";
echo " Timestamp: {$summary['first_event']['timestamp']}\n\n";
echo "Last Event:\n";
echo " Version: {$summary['last_event']['version']}\n";
echo " Type: {$summary['last_event']['type']}\n";
echo " Timestamp: {$summary['last_event']['timestamp']}\n\n";
echo "Time Span:\n";
echo " Duration: {$summary['time_span']['duration_hours']} hours\n";
echo " Avg Events/Day: {$summary['average_events_per_day']}\n\n";
echo "Event Type Distribution:\n";
foreach ($summary['event_types'] as $type => $count) {
$percentage = round(($count / $summary['total_events']) * 100, 1);
printf(" %-30s %5d (%5.1f%%)\n", $type, $count, $percentage);
}
return ExitCode::SUCCESS;
}
/**
* Rebuild projection
*
* Usage: php console.php replay:rebuild-projection <projection-name>
*/
#[ConsoleCommand('replay:rebuild-projection', 'Rebuild projection')]
public function rebuildProjection(ConsoleInput $input): int
{
$projectionName = $input->getArgument('projection-name');
if (!$projectionName) {
echo "Error: Projection name required\n";
echo "Usage: replay:rebuild-projection <projection-name>\n";
return ExitCode::ERROR;
}
echo "Rebuilding projection: {$projectionName}...\n";
try {
$result = $this->projectionRebuilder->rebuildProjection($projectionName);
echo "✓ Projection rebuilt successfully\n";
echo "Strategy: {$result['strategy']}\n";
echo "Status: {$result['status']}\n";
echo "Last Version: {$result['last_version']}\n";
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "Error rebuilding projection: {$e->getMessage()}\n";
return ExitCode::ERROR;
}
}
/**
* Rebuild all projections
*
* Usage: php console.php replay:rebuild-all
*/
#[ConsoleCommand('replay:rebuild-all', 'Rebuild all projections')]
public function rebuildAll(ConsoleInput $input): int
{
echo "Rebuilding all projections...\n";
try {
$results = $this->projectionRebuilder->rebuildAllProjections();
echo "✓ All projections rebuilt successfully\n";
echo "Total: " . count($results) . " projection(s)\n";
foreach ($results as $result) {
echo " - {$result->projectionName}: {$result->status->value}\n";
}
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "Error rebuilding projections: {$e->getMessage()}\n";
return ExitCode::ERROR;
}
}
private function createAggregateId(string $id): AggregateId
{
return new class($id) implements AggregateId {
public function __construct(private readonly string $id) {}
public function toString(): string { return $this->id; }
};
}
private function createStrategy(string $type, ConsoleInput $input): ReplayStrategy
{
return match ($type) {
'full' => new FullReplayStrategy(),
'from-version' => new FromVersionReplayStrategy(
(int) ($input->getArgument('version') ?? 0)
),
'time-range' => TimeRangeReplayStrategy::lastDays(
(int) ($input->getArgument('days') ?? 7)
),
'today' => TimeRangeReplayStrategy::today(),
'yesterday' => TimeRangeReplayStrategy::yesterday(),
default => new FullReplayStrategy()
};
}
}

View File

@@ -0,0 +1,325 @@
<?php
declare(strict_types=1);
namespace App\Framework\EventSourcing\Sagas\Examples;
use App\Framework\EventSourcing\Sagas\AbstractSaga;
use App\Framework\EventSourcing\DomainEvent;
/**
* Order Fulfillment Saga Example
*
* Orchestrates order fulfillment process across multiple steps:
* 1. Payment processing
* 2. Inventory reservation
* 3. Shipping
* 4. Notification
*
* Demonstrates compensation logic for failed steps
*/
final readonly class OrderFulfillmentSaga extends AbstractSaga
{
public function getName(): string
{
return 'order_fulfillment';
}
public function subscribedTo(): array
{
return [
OrderPlacedEvent::class,
PaymentProcessedEvent::class,
InventoryReservedEvent::class,
OrderShippedEvent::class,
PaymentFailedEvent::class,
InventoryNotAvailableEvent::class,
ShippingFailedEvent::class,
];
}
protected function handleEvent(DomainEvent $event): array
{
return match ($event::class) {
OrderPlacedEvent::class => $this->handleOrderPlaced($event),
PaymentProcessedEvent::class => $this->handlePaymentProcessed($event),
InventoryReservedEvent::class => $this->handleInventoryReserved($event),
OrderShippedEvent::class => $this->handleOrderShipped($event),
PaymentFailedEvent::class => throw new \RuntimeException('Payment failed'),
InventoryNotAvailableEvent::class => throw new \RuntimeException('Inventory not available'),
ShippingFailedEvent::class => throw new \RuntimeException('Shipping failed'),
default => []
};
}
private function handleOrderPlaced(OrderPlacedEvent $event): array
{
// Initialize saga data
$this->updateData([
'orderId' => $event->orderId,
'customerId' => $event->customerId,
'totalAmount' => $event->totalAmount,
'items' => $event->items,
'step' => 'payment_processing'
]);
$this->setStepCount(4); // 4 steps: payment, inventory, shipping, notification
// Step 1: Process payment
return [
new ProcessPaymentCommand(
orderId: $event->orderId,
amount: $event->totalAmount,
customerId: $event->customerId
)
];
}
private function handlePaymentProcessed(PaymentProcessedEvent $event): array
{
// Payment successful - update saga data
$this->updateData([
'paymentId' => $event->paymentId,
'step' => 'inventory_reservation'
]);
// Step 2: Reserve inventory
$items = $this->getData('items');
return [
new ReserveInventoryCommand(
orderId: $event->orderId,
items: $items
)
];
}
private function handleInventoryReserved(InventoryReservedEvent $event): array
{
// Inventory reserved - update saga data
$this->updateData([
'reservationId' => $event->reservationId,
'step' => 'shipping'
]);
// Step 3: Ship order
$orderId = $this->getData('orderId');
$customerId = $this->getData('customerId');
return [
new ShipOrderCommand(
orderId: $orderId,
customerId: $customerId,
items: $event->reservedItems
)
];
}
private function handleOrderShipped(OrderShippedEvent $event): array
{
// Order shipped - update saga data
$this->updateData([
'trackingNumber' => $event->trackingNumber,
'step' => 'notification'
]);
// Step 4: Send notification
$customerId = $this->getData('customerId');
$orderId = $this->getData('orderId');
$commands = [
new SendOrderConfirmationCommand(
customerId: $customerId,
orderId: $orderId,
trackingNumber: $event->trackingNumber
)
];
// Mark saga as completed
$this->complete();
return $commands;
}
protected function getCompensationCommands(): array
{
$compensationCommands = [];
$currentStep = $this->getData('step');
// Compensate in reverse order of execution
match ($currentStep) {
'shipping' => $compensationCommands = array_merge(
$compensationCommands,
$this->compensateInventoryReservation(),
$this->compensatePayment()
),
'inventory_reservation' => $compensationCommands = array_merge(
$compensationCommands,
$this->compensatePayment()
),
'payment_processing' => $compensationCommands = $this->compensatePayment(),
default => []
};
// Always notify customer about cancellation
$compensationCommands[] = new SendOrderCancellationCommand(
customerId: $this->getData('customerId'),
orderId: $this->getData('orderId'),
reason: $this->state->errorMessage ?? 'Order fulfillment failed'
);
return $compensationCommands;
}
private function compensatePayment(): array
{
if (!$this->hasData('paymentId')) {
return [];
}
return [
new RefundPaymentCommand(
paymentId: $this->getData('paymentId'),
orderId: $this->getData('orderId'),
amount: $this->getData('totalAmount')
)
];
}
private function compensateInventoryReservation(): array
{
if (!$this->hasData('reservationId')) {
return [];
}
return [
new ReleaseInventoryCommand(
reservationId: $this->getData('reservationId'),
orderId: $this->getData('orderId')
)
];
}
}
// Example Events (would be in separate files)
final readonly class OrderPlacedEvent implements DomainEvent
{
public function __construct(
public string $orderId,
public string $customerId,
public int $totalAmount,
public array $items
) {}
}
final readonly class PaymentProcessedEvent implements DomainEvent
{
public function __construct(
public string $orderId,
public string $paymentId,
public int $amount
) {}
}
final readonly class InventoryReservedEvent implements DomainEvent
{
public function __construct(
public string $orderId,
public string $reservationId,
public array $reservedItems
) {}
}
final readonly class OrderShippedEvent implements DomainEvent
{
public function __construct(
public string $orderId,
public string $trackingNumber
) {}
}
final readonly class PaymentFailedEvent implements DomainEvent
{
public function __construct(
public string $orderId,
public string $reason
) {}
}
final readonly class InventoryNotAvailableEvent implements DomainEvent
{
public function __construct(
public string $orderId,
public array $unavailableItems
) {}
}
final readonly class ShippingFailedEvent implements DomainEvent
{
public function __construct(
public string $orderId,
public string $reason
) {}
}
// Example Commands (would be in separate files)
final readonly class ProcessPaymentCommand
{
public function __construct(
public string $orderId,
public int $amount,
public string $customerId
) {}
}
final readonly class ReserveInventoryCommand
{
public function __construct(
public string $orderId,
public array $items
) {}
}
final readonly class ShipOrderCommand
{
public function __construct(
public string $orderId,
public string $customerId,
public array $items
) {}
}
final readonly class SendOrderConfirmationCommand
{
public function __construct(
public string $customerId,
public string $orderId,
public string $trackingNumber
) {}
}
final readonly class RefundPaymentCommand
{
public function __construct(
public string $paymentId,
public string $orderId,
public int $amount
) {}
}
final readonly class ReleaseInventoryCommand
{
public function __construct(
public string $reservationId,
public string $orderId
) {}
}
final readonly class SendOrderCancellationCommand
{
public function __construct(
public string $customerId,
public string $orderId,
public string $reason
) {}
}

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Framework\EventSourcing\Sagas\Examples;
use App\Framework\EventSourcing\Sagas\AbstractSaga;
use App\Framework\EventSourcing\DomainEvent;
/**
* User Onboarding Saga Example
*
* Orchestrates user onboarding process:
* 1. Email verification
* 2. Profile setup
* 3. Welcome email
* 4. Feature tour activation
*
* Demonstrates time-based saga with multi-step coordination
*/
final readonly class UserOnboardingSaga extends AbstractSaga
{
public function getName(): string
{
return 'user_onboarding';
}
public function subscribedTo(): array
{
return [
UserRegisteredEvent::class,
EmailVerifiedEvent::class,
ProfileCompletedEvent::class,
WelcomeEmailSentEvent::class,
];
}
protected function handleEvent(DomainEvent $event): array
{
return match ($event::class) {
UserRegisteredEvent::class => $this->handleUserRegistered($event),
EmailVerifiedEvent::class => $this->handleEmailVerified($event),
ProfileCompletedEvent::class => $this->handleProfileCompleted($event),
WelcomeEmailSentEvent::class => $this->handleWelcomeEmailSent($event),
default => []
};
}
private function handleUserRegistered(UserRegisteredEvent $event): array
{
// Initialize saga
$this->updateData([
'userId' => $event->userId,
'email' => $event->email,
'registeredAt' => $event->registeredAt,
'step' => 'email_verification'
]);
$this->setStepCount(4); // 4 steps total
// Step 1: Send verification email
return [
new SendVerificationEmailCommand(
userId: $event->userId,
email: $event->email
)
];
}
private function handleEmailVerified(EmailVerifiedEvent $event): array
{
// Email verified - prompt for profile setup
$this->updateData([
'emailVerifiedAt' => $event->verifiedAt,
'step' => 'profile_setup'
]);
// Step 2: Send profile setup reminder
return [
new SendProfileSetupReminderCommand(
userId: $this->getData('userId'),
email: $this->getData('email')
)
];
}
private function handleProfileCompleted(ProfileCompletedEvent $event): array
{
// Profile completed - send welcome email
$this->updateData([
'profileCompletedAt' => $event->completedAt,
'profileData' => $event->profileData,
'step' => 'welcome_email'
]);
// Step 3: Send welcome email
return [
new SendWelcomeEmailCommand(
userId: $this->getData('userId'),
email: $this->getData('email'),
userName: $event->profileData['name'] ?? 'User'
)
];
}
private function handleWelcomeEmailSent(WelcomeEmailSentEvent $event): array
{
// Welcome email sent - activate feature tour
$this->updateData([
'welcomeEmailSentAt' => $event->sentAt,
'step' => 'feature_tour'
]);
// Step 4: Activate feature tour
$commands = [
new ActivateFeatureTourCommand(
userId: $this->getData('userId')
)
];
// Onboarding complete
$this->complete();
return $commands;
}
protected function getCompensationCommands(): array
{
// User onboarding has limited compensation
// Mainly just cleanup and notifications
$compensationCommands = [];
$userId = $this->getData('userId');
// Deactivate any features that were enabled
if ($this->hasData('welcomeEmailSentAt')) {
$compensationCommands[] = new DeactivateFeatureTourCommand($userId);
}
// Send onboarding failure notification to admins
$compensationCommands[] = new NotifyOnboardingFailureCommand(
userId: $userId,
email: $this->getData('email'),
failedStep: $this->getData('step'),
reason: $this->state->errorMessage ?? 'Unknown error'
);
return $compensationCommands;
}
}
// Example Events
final readonly class UserRegisteredEvent implements DomainEvent
{
public function __construct(
public string $userId,
public string $email,
public string $registeredAt
) {}
}
final readonly class EmailVerifiedEvent implements DomainEvent
{
public function __construct(
public string $userId,
public string $verifiedAt
) {}
}
final readonly class ProfileCompletedEvent implements DomainEvent
{
public function __construct(
public string $userId,
public array $profileData,
public string $completedAt
) {}
}
final readonly class WelcomeEmailSentEvent implements DomainEvent
{
public function __construct(
public string $userId,
public string $sentAt
) {}
}
// Example Commands
final readonly class SendVerificationEmailCommand
{
public function __construct(
public string $userId,
public string $email
) {}
}
final readonly class SendProfileSetupReminderCommand
{
public function __construct(
public string $userId,
public string $email
) {}
}
final readonly class SendWelcomeEmailCommand
{
public function __construct(
public string $userId,
public string $email,
public string $userName
) {}
}
final readonly class ActivateFeatureTourCommand
{
public function __construct(
public string $userId
) {}
}
final readonly class DeactivateFeatureTourCommand
{
public function __construct(
public string $userId
) {}
}
final readonly class NotifyOnboardingFailureCommand
{
public function __construct(
public string $userId,
public string $email,
public string $failedStep,
public string $reason
) {}
}

View File

@@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace App\Framework\EventSourcing\Sagas;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ExitCode;
/**
* Saga Console Commands
*
* Manage sagas via CLI
*/
final readonly class SagaCommands
{
public function __construct(
private SagaManager $sagaManager,
private SagaRepository $repository
) {}
/**
* Start a new saga
*
* Usage: php console.php saga:start <saga-name> [data-json]
*/
#[ConsoleCommand('saga:start', 'Start a new saga')]
public function start(ConsoleInput $input): int
{
$sagaName = $input->getArgument('saga-name');
$dataJson = $input->getArgument('data-json');
if (!$sagaName) {
echo "Error: Saga name required\n";
echo "Usage: saga:start <saga-name> [data-json]\n";
return ExitCode::ERROR;
}
$initialData = [];
if ($dataJson) {
$initialData = json_decode($dataJson, true);
if (json_last_error() !== JSON_ERROR_NONE) {
echo "Error: Invalid JSON data\n";
return ExitCode::ERROR;
}
}
try {
$sagaId = $this->sagaManager->startSaga($sagaName, $initialData);
echo "✓ Saga started successfully\n";
echo "Saga ID: {$sagaId->toString()}\n";
echo "Saga Name: {$sagaName}\n";
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "Error starting saga: {$e->getMessage()}\n";
return ExitCode::ERROR;
}
}
/**
* Show saga status
*
* Usage: php console.php saga:status <saga-id>
*/
#[ConsoleCommand('saga:status', 'Show saga status')]
public function status(ConsoleInput $input): int
{
$sagaIdStr = $input->getArgument('saga-id');
if (!$sagaIdStr) {
echo "Error: Saga ID required\n";
return ExitCode::ERROR;
}
$sagaId = SagaId::fromString($sagaIdStr);
$state = $this->sagaManager->getSagaState($sagaId);
if (!$state) {
echo "Saga not found: {$sagaIdStr}\n";
return ExitCode::ERROR;
}
echo "Saga Status:\n";
echo str_repeat('=', 60) . "\n";
echo "Saga ID: {$state->sagaId->toString()}\n";
echo "Saga Name: {$state->sagaName}\n";
echo "Status: {$state->status->value}\n";
echo "Progress: {$state->getProgress()}% ({$state->completedSteps}/{$state->stepCount} steps)\n";
echo "Started: {$state->startedAt->format('Y-m-d H:i:s')}\n";
if ($state->completedAt) {
echo "Completed: {$state->completedAt->format('Y-m-d H:i:s')}\n";
}
if ($state->errorMessage) {
echo "Error: {$state->errorMessage}\n";
}
echo "\nSaga Data:\n";
echo json_encode($state->data, JSON_PRETTY_PRINT) . "\n";
return ExitCode::SUCCESS;
}
/**
* List all sagas
*
* Usage: php console.php saga:list [status]
*/
#[ConsoleCommand('saga:list', 'List all sagas')]
public function list(ConsoleInput $input): int
{
$statusFilter = $input->getArgument('status');
$sagas = $statusFilter
? $this->repository->findByStatus(SagaStatus::from($statusFilter))
: $this->repository->findAll();
if (empty($sagas)) {
echo "No sagas found\n";
return ExitCode::SUCCESS;
}
echo "Sagas:\n";
echo str_repeat('=', 80) . "\n";
printf("%-36s %-25s %-12s %-10s\n", 'ID', 'Name', 'Status', 'Progress');
echo str_repeat('-', 80) . "\n";
foreach ($sagas as $saga) {
$state = $saga->getState();
printf(
"%-36s %-25s %-12s %6.1f%%\n",
$state->sagaId->toString(),
$state->sagaName,
$state->status->value,
$state->getProgress()
);
}
echo "\nTotal: " . count($sagas) . " saga(s)\n";
return ExitCode::SUCCESS;
}
/**
* Show running sagas
*
* Usage: php console.php saga:running
*/
#[ConsoleCommand('saga:running', 'Show running sagas')]
public function running(ConsoleInput $input): int
{
$runningSagas = $this->sagaManager->getRunningSagas();
if (empty($runningSagas)) {
echo "No running sagas\n";
return ExitCode::SUCCESS;
}
echo "Running Sagas:\n";
echo str_repeat('=', 80) . "\n";
foreach ($runningSagas as $saga) {
$state = $saga->getState();
echo "ID: {$state->sagaId->toString()}\n";
echo "Name: {$state->sagaName}\n";
echo "Progress: {$state->getProgress()}% ({$state->completedSteps}/{$state->stepCount} steps)\n";
echo "Started: {$state->startedAt->format('Y-m-d H:i:s')}\n";
echo str_repeat('-', 80) . "\n";
}
echo "\nTotal: " . count($runningSagas) . " running saga(s)\n";
return ExitCode::SUCCESS;
}
/**
* Complete saga manually
*
* Usage: php console.php saga:complete <saga-id>
*/
#[ConsoleCommand('saga:complete', 'Complete saga manually')]
public function complete(ConsoleInput $input): int
{
$sagaIdStr = $input->getArgument('saga-id');
if (!$sagaIdStr) {
echo "Error: Saga ID required\n";
return ExitCode::ERROR;
}
try {
$sagaId = SagaId::fromString($sagaIdStr);
$this->sagaManager->completeSaga($sagaId);
echo "✓ Saga completed successfully\n";
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "Error completing saga: {$e->getMessage()}\n";
return ExitCode::ERROR;
}
}
/**
* Compensate (rollback) saga
*
* Usage: php console.php saga:compensate <saga-id> <reason>
*/
#[ConsoleCommand('saga:compensate', 'Compensate (rollback) saga')]
public function compensate(ConsoleInput $input): int
{
$sagaIdStr = $input->getArgument('saga-id');
$reason = $input->getArgument('reason') ?? 'Manual compensation';
if (!$sagaIdStr) {
echo "Error: Saga ID required\n";
return ExitCode::ERROR;
}
try {
$sagaId = SagaId::fromString($sagaIdStr);
$saga = $this->sagaManager->getSaga($sagaId);
if (!$saga) {
echo "Saga not found: {$sagaIdStr}\n";
return ExitCode::ERROR;
}
echo "Compensating saga: {$saga->getName()}...\n";
$this->sagaManager->compensateSaga($saga, $reason);
echo "✓ Saga compensated successfully\n";
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "Error compensating saga: {$e->getMessage()}\n";
return ExitCode::ERROR;
}
}
/**
* Delete saga
*
* Usage: php console.php saga:delete <saga-id>
*/
#[ConsoleCommand('saga:delete', 'Delete saga')]
public function delete(ConsoleInput $input): int
{
$sagaIdStr = $input->getArgument('saga-id');
if (!$sagaIdStr) {
echo "Error: Saga ID required\n";
return ExitCode::ERROR;
}
try {
$sagaId = SagaId::fromString($sagaIdStr);
echo "Deleting saga: {$sagaIdStr}...\n";
$this->repository->delete($sagaId);
echo "✓ Saga deleted successfully\n";
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "Error deleting saga: {$e->getMessage()}\n";
return ExitCode::ERROR;
}
}
}