Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TASK: Add force strategy to the rebase workspace command #4939

Merged
merged 2 commits into from
Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
@contentrepository @adapters=DoctrineDBAL
Feature: Workspace rebasing - conflicting changes

This is an END TO END test; testing all layers of the related functionality step by step together

Basic fixture setup is:
- root workspace with a single "root" node inside; and an additional child node.
- then, a nested workspace is created based on the "root" node

Background:
Given using no content dimensions
And using the following node types:
"""yaml
'Neos.ContentRepository.Testing:Content':
properties:
text:
type: string
"""
And using identifier "default", I define a content repository
And I am in content repository "default"
And the command CreateRootWorkspace is executed with payload:
| Key | Value |
| workspaceName | "live" |
| newContentStreamId | "cs-identifier" |
And the graph projection is fully up to date
And I am in the active content stream of workspace "live" and dimension space point {}
And the command CreateRootNodeAggregateWithNode is executed with payload:
| Key | Value |
| nodeAggregateId | "lady-eleonode-rootford" |
| nodeTypeName | "Neos.ContentRepository:Root" |

And the following CreateNodeAggregateWithNode commands are executed:
| nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName |
| nody-mc-nodeface | Neos.ContentRepository.Testing:Content | lady-eleonode-rootford | child |

And the command SetNodeProperties is executed with payload:
| Key | Value |
| nodeAggregateId | "nody-mc-nodeface" |
| originDimensionSpacePoint | {} |
| propertyValues | {"text": "Original"} |
# we need to ensure that the projections are up to date now; otherwise a content stream is forked with an out-
# of-date base version. This means the content stream can never be merged back, but must always be rebased.
And the graph projection is fully up to date
And the command CreateWorkspace is executed with payload:
| Key | Value |
| workspaceName | "user-test" |
| baseWorkspaceName | "live" |
| newContentStreamId | "user-cs-identifier" |
| workspaceOwner | "owner-identifier" |
And the graph projection is fully up to date

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
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,4 @@ Feature: Workspace discarding - basic functionality
And the graph projection is fully up to date

Then workspace user-ws-two has status OUTDATED

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -385,41 +386,26 @@ 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 {
$contentRepository->handle($originalCommand)->block();
// 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);
}
}
}
);

// 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,
Expand Down Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -31,20 +32,29 @@
*/
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);
}

/**
* Call this method if you want to run this command fully deterministically, f.e. during test cases
*/
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

/*
* This file is part of the Neos.ContentRepository package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/

declare(strict_types=1);

namespace Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto;

/**
* The strategy how to handle errors during workspace rebase
*
* - fail (default) ensures conflicts are not ignored but reported
* - force will rebase even if some conflicting events could have to be rebased
*
* @api DTO of {@see RebaseWorkspace} command
*/
enum RebaseErrorHandlingStrategy: string implements \JsonSerializable
{
/**
* This strategy rebasing will fail if conflicts are detected and the "WorkspaceRebaseFailed" event is added.
*/
case STRATEGY_FAIL = 'fail';

/**
* This strategy means all events that can be applied are rebased and conflicting events are ignored
*/
case STRATEGY_FORCE = 'force';

/**
* @return string
*/
public function jsonSerialize(): string
{
return $this->value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
24 changes: 18 additions & 6 deletions Neos.Neos/Classes/Command/WorkspaceCommandController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
}

Expand Down
Loading