diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W9-WorkspaceDiscarding/02-DiscardWorkspace.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W9-WorkspaceDiscarding/02-DiscardWorkspace.feature index e2095bab136..f87a6226557 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W9-WorkspaceDiscarding/02-DiscardWorkspace.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W9-WorkspaceDiscarding/02-DiscardWorkspace.feature @@ -161,3 +161,70 @@ Feature: Workspace discarding - basic functionality And the graph projection is fully up to date Then workspace user-ws-two has status OUTDATED + + Scenario: Conflicting changes lead to OUTDATED_CONFLICT which can be recovered from via forced rebase + + When the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-ws-one" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-one" | + | workspaceOwner | "owner-identifier" | + And the graph projection is fully up to date + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-ws-two" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-two" | + | workspaceOwner | "owner-identifier" | + And the graph projection is fully up to date + + When the command RemoveNodeAggregate is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | nodeVariantSelectionStrategy | "allVariants" | + | coveredDimensionSpacePoint | {} | + | workspaceName | "user-ws-one" | + And the graph projection is fully up to date + + When the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-ws-two" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "Modified"} | + And the graph projection is fully up to date + + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "noderus-secundus" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | parentNodeAggregateId | "lady-eleonode-rootford" | + | originDimensionSpacePoint | {} | + | workspaceName | "user-ws-two" | + And the graph projection is fully up to date + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-ws-two" | + | nodeAggregateId | "noderus-secundus" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "The other node"} | + And the graph projection is fully up to date + + And the command PublishWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-ws-one" | + And the graph projection is fully up to date + + Then workspace user-ws-two has status OUTDATED + + When the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-ws-two" | + | rebasedContentStreamId | "user-cs-two-rebased" | + | rebaseErrorHandlingStrategy | "force" | + And the graph projection is fully up to date + + Then workspace user-ws-two has status UP_TO_DATE + And I expect a node identified by user-cs-two-rebased;noderus-secundus;{} to exist in the content graph diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php index f631052e700..fe3a1350422 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php @@ -30,6 +30,7 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardIndividualNodesFromWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\WorkspaceRebaseStatistics; use Neos\ContentRepository\Core\Feature\ContentStreamCreation\Command\CreateContentStream; use Neos\ContentRepository\Core\Feature\ContentStreamForking\Command\ForkContentStream; @@ -385,7 +386,7 @@ private function handleRebaseWorkspace( $rebaseStatistics = new WorkspaceRebaseStatistics(); $this->withContentStreamIdToUse( $command->rebasedContentStreamId, - function () use ($originalCommands, $contentRepository, $rebaseStatistics, $workspaceContentStreamName, $baseWorkspace): void { + function () use ($originalCommands, $contentRepository, $rebaseStatistics): void { foreach ($originalCommands as $i => $originalCommand) { // We no longer need to adjust commands as the workspace stays the same try { @@ -393,25 +394,10 @@ function () use ($originalCommands, $contentRepository, $rebaseStatistics, $work // if we came this far, we know the command was applied successfully. $rebaseStatistics->commandRebaseSuccess(); } catch (\Exception $e) { - $fullCommandListSoFar = ''; - for ($a = 0; $a <= $i; $a++) { - $fullCommandListSoFar .= "\n - " . get_class($originalCommands[$a]); - - if ($originalCommands[$a] instanceof \JsonSerializable) { - $fullCommandListSoFar .= ' ' . json_encode($originalCommands[$a]); - } - } - $rebaseStatistics->commandRebaseError(sprintf( - "The content stream %s cannot be rebased. Error with command %d (%s)" - . " - see nested exception for details.\n\n The base workspace %s is at content stream %s." - . "\n The full list of commands applied so far is: %s", - $workspaceContentStreamName->value, - $i, + "Error with command %s in sequence-number %d", get_class($originalCommand), - $baseWorkspace->workspaceName->value, - $baseWorkspace->currentContentStreamId->value, - $fullCommandListSoFar + $i ), $e); } } @@ -419,7 +405,7 @@ function () use ($originalCommands, $contentRepository, $rebaseStatistics, $work ); // if we got so far without an Exception, we can switch the Workspace's active Content stream. - if (!$rebaseStatistics->hasErrors()) { + if ($command->rebaseErrorHandlingStrategy === RebaseErrorHandlingStrategy::STRATEGY_FORCE || $rebaseStatistics->hasErrors() === false) { $events = Events::with( new WorkspaceWasRebased( $command->workspaceName, @@ -479,7 +465,7 @@ private function extractCommandsFromContentStreamMetadata( * The "fromArray" might be declared via {@see RebasableToOtherWorkspaceInterface::fromArray()} * or any other command can just implement it. */ - $commands[] = $commandToRebaseClass::fromArray($commandToRebasePayload); + $commands[$eventEnvelope->sequenceNumber->value] = $commandToRebaseClass::fromArray($commandToRebasePayload); } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Command/RebaseWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Command/RebaseWorkspace.php index 867bf88806e..5e9aa2cbc91 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Command/RebaseWorkspace.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Command/RebaseWorkspace.php @@ -15,6 +15,7 @@ namespace Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -31,13 +32,14 @@ */ private function __construct( public WorkspaceName $workspaceName, - public ContentStreamId $rebasedContentStreamId + public ContentStreamId $rebasedContentStreamId, + public RebaseErrorHandlingStrategy $rebaseErrorHandlingStrategy ) { } public static function create(WorkspaceName $workspaceName): self { - return new self($workspaceName, ContentStreamId::create()); + return new self($workspaceName, ContentStreamId::create(), RebaseErrorHandlingStrategy::STRATEGY_FAIL); } /** @@ -45,6 +47,14 @@ public static function create(WorkspaceName $workspaceName): self */ public function withRebasedContentStreamId(ContentStreamId $newContentStreamId): self { - return new self($this->workspaceName, $newContentStreamId); + return new self($this->workspaceName, $newContentStreamId, $this->rebaseErrorHandlingStrategy); + } + + /** + * Call this method if you want to run this command with a specific error handling strategy like force + */ + public function withErrorHandlingStrategy(RebaseErrorHandlingStrategy $rebaseErrorHandlingStrategy): self + { + return new self($this->workspaceName, $this->rebasedContentStreamId, $rebaseErrorHandlingStrategy); } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Dto/RebaseErrorHandlingStrategy.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Dto/RebaseErrorHandlingStrategy.php new file mode 100644 index 00000000000..4656abea441 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Dto/RebaseErrorHandlingStrategy.php @@ -0,0 +1,44 @@ +value; + } +} diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php index e28b6d7ad9e..c3aa1be6764 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php @@ -15,6 +15,7 @@ namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features; use Behat\Gherkin\Node\TableNode; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; @@ -105,6 +106,9 @@ public function theCommandRebaseWorkspaceIsExecutedWithPayload(TableNode $payloa if (isset($commandArguments['rebasedContentStreamId'])) { $command = $command->withRebasedContentStreamId(ContentStreamId::fromString($commandArguments['rebasedContentStreamId'])); } + if (isset($commandArguments['rebaseErrorHandlingStrategy'])) { + $command = $command->withErrorHandlingStrategy(RebaseErrorHandlingStrategy::from($commandArguments['rebaseErrorHandlingStrategy'])); + } $this->lastCommandOrEventResult = $this->currentContentRepository->handle($command); } diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index c152dc952d9..a643e47f2b5 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -23,7 +23,9 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\Projection\Workspace\Workspace; +use Neos\ContentRepository\Core\Projection\Workspace\WorkspaceStatus; use Neos\ContentRepository\Core\Service\WorkspaceMaintenanceServiceFactory; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\User\UserId; @@ -112,23 +114,33 @@ public function discardCommand(string $workspace, string $contentRepositoryIdent * * @param string $workspace Name of the workspace, for example "user-john" * @param string $contentRepositoryIdentifier + * @param bool $force Rebase all events that do not conflict * @throws StopCommandException */ - public function rebaseCommand(string $workspace, string $contentRepositoryIdentifier = 'default'): void + public function rebaseCommand(string $workspace, string $contentRepositoryIdentifier = 'default', bool $force = false): void { $contentRepositoryId = ContentRepositoryId::fromString($contentRepositoryIdentifier); $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); try { - $contentRepository->handle( - RebaseWorkspace::create( - WorkspaceName::fromString($workspace), - ) - )->block(); + $rebaseCommand = RebaseWorkspace::create( + WorkspaceName::fromString($workspace), + ); + if ($force) { + $rebaseCommand = $rebaseCommand->withErrorHandlingStrategy(RebaseErrorHandlingStrategy::STRATEGY_FORCE); + } + $contentRepository->handle($rebaseCommand)->block(); } catch (WorkspaceDoesNotExist $exception) { $this->outputLine('Workspace "%s" does not exist', [$workspace]); $this->quit(1); } + + $workspaceObject = $contentRepository->getWorkspaceFinder()->findOneByName(WorkspaceName::fromString($workspace)); + if ($workspaceObject && $workspaceObject->status === WorkspaceStatus::OUTDATED_CONFLICT) { + $this->outputLine('Rebasing of workspace %s is not possible due to conflicts. You can try the --force option.', [$workspace]); + $this->quit(1); + } + $this->outputLine('Rebased workspace %s', [$workspace]); }