From 1f8d400619fe82694273ca6c664838baf2a07237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Mu=CC=88ller?= Date: Wed, 16 Oct 2024 20:33:24 +0200 Subject: [PATCH 1/3] BUGFIX: Partial publish breaks uri and change Projection A partial publish results in events annotated for "live" workspace yet are not in the "live" content stream. This is due to a fork of the live content stream being created with the partially published events in, which is then published to the actual live content stream. This leaves behind duplicate events both containing "workspaceName: live" yet only one of them is in the live content stream. A catchup or replay will fail however due to duplicate database entries for the reflections relying on anything with "workspaceName: live" being actually in the live content stream. The provided test fails showing the behavior. --- .../FrontendRouting/PartialPublish.feature | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 Neos.Neos/Tests/Behavior/Features/FrontendRouting/PartialPublish.feature diff --git a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/PartialPublish.feature b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/PartialPublish.feature new file mode 100644 index 00000000000..6c82bbeacbb --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/PartialPublish.feature @@ -0,0 +1,68 @@ +@flowEntities @contentrepository +Feature: Test cases for partial publish to live and uri path generation + + Scenario: Create Document in another workspace and partially publish to live + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | example | source,peer,peerSpec | peerSpec->peer | + And using the following node types: + """yaml + 'Neos.Neos:Sites': + superTypes: + 'Neos.ContentRepository:Root': true + 'Neos.Neos:Document': + properties: + uriPathSegment: + type: string + 'Neos.Neos:Site': + superTypes: + 'Neos.Neos:Document': true + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And I am user identified by "initiating-user-identifier" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" and dimension space point {"example":"source"} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.Neos:Sites" | + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "shernode-homes" | + | nodeTypeName | "Neos.Neos:Site" | + | parentNodeAggregateId | "lady-eleonode-rootford" | + | originDimensionSpacePoint | {"example":"source"} | + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "myworkspace" | + | baseWorkspaceName | "live" | + | workspaceTitle | "My Personal Workspace" | + | workspaceDescription | "" | + | newContentStreamId | "cs-myworkspace" | + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "justsomepage" | + | nodeTypeName | "Neos.Neos:Document" | + | parentNodeAggregateId | "shernode-homes" | + | originDimensionSpacePoint | {"example":"source"} | + | properties | {"uriPathSegment": "just"}| + | workspaceName | "myworkspace" | + And the command PublishIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "myworkspace" | + | nodesToPublish | [{"nodeAggregateId": "justsomepage", "dimensionSpacePoint": {"example":"source"}}] | + + Then I expect the documenturipath table to contain exactly: + # source: 65901ded4f068dac14ad0dce4f459b29 + # spec: 9a723c057afa02982dae9d0b541739be + # leafSpec: c60c44685475d0e2e4f2b964e6158ce2 + | dimensionspacepointhash | uripath | nodeaggregateidpath | nodeaggregateid | parentnodeaggregateid | precedingnodeaggregateid | succeedingnodeaggregateid | nodetypename | + | "2ca4fae2f65267c94c85602df0cbb728" | "" | "lady-eleonode-rootford" | "lady-eleonode-rootford" | null | null | null | "Neos.Neos:Sites" | + | "65901ded4f068dac14ad0dce4f459b29" | "" | "lady-eleonode-rootford" | "lady-eleonode-rootford" | null | null | null | "Neos.Neos:Sites" | + | "fbe53ddc3305685fbb4dbf529f283a0e" | "" | "lady-eleonode-rootford" | "lady-eleonode-rootford" | null | null | null | "Neos.Neos:Sites" | + | "65901ded4f068dac14ad0dce4f459b29" | "" | "lady-eleonode-rootford/shernode-homes" | "shernode-homes" | "lady-eleonode-rootford" | null | null | "Neos.Neos:Site" | + | "65901ded4f068dac14ad0dce4f459b29" | "" | "lady-eleonode-rootford/shernode-homes/justsomepage" | "justsomepage" | "shernode-homes" | null | null | "Neos.Neos:Document" | From 3dd906964c7ce9c347680991c3c39813b22ab7c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Mu=CC=88ller?= Date: Wed, 16 Oct 2024 22:33:01 +0200 Subject: [PATCH 2/3] BUGFIX: Avoid live events in non live content stream --- .../Feature/WorkspaceCommandHandler.php | 49 +++++++++---------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php index ea4f29c6232..16adec683e7 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php @@ -69,7 +69,9 @@ use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceHasNoBaseWorkspaceName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; +use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceDescription; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceTitle; use Neos\EventStore\EventStoreInterface; use Neos\EventStore\Exception\ConcurrencyException; use Neos\EventStore\Model\Event\EventType; @@ -470,34 +472,30 @@ private function handlePublishIndividualNodesFromWorkspace( /** @var array $matchingCommands */ /** @var array $remainingCommands */ - // 3) fork a new contentStream, based on the base WS, and apply MATCHING + // 3) fork a temporary workspace, based on the base WS, and apply MATCHING + $matchingWorkspaceName = WorkspaceName::transliterateFromString('tmp' . str_replace('-', '', $command->contentStreamIdForMatchingPart->value)); $commandHandlingDependencies->handle( - ForkContentStream::create( - $command->contentStreamIdForMatchingPart, - $baseWorkspace->currentContentStreamId, + CreateWorkspace::create( + $matchingWorkspaceName, + $baseWorkspace->workspaceName, + WorkspaceTitle::fromString('tmp'), + WorkspaceDescription::fromString(''), + $command->contentStreamIdForMatchingPart ) ); try { // 4) using the new content stream, apply the matching commands - $commandHandlingDependencies->overrideContentStreamId( - $baseWorkspace->workspaceName, - $command->contentStreamIdForMatchingPart, - function () use ($matchingCommands, $commandHandlingDependencies, $baseWorkspace): void { - foreach ($matchingCommands as $matchingCommand) { - if (!($matchingCommand instanceof RebasableToOtherWorkspaceInterface)) { - throw new \RuntimeException( - 'ERROR: The command ' . get_class($matchingCommand) - . ' does not implement ' . RebasableToOtherWorkspaceInterface::class . '; but it should!' - ); - } - - $commandHandlingDependencies->handle($matchingCommand->createCopyForWorkspace( - $baseWorkspace->workspaceName, - )); - } + foreach ($matchingCommands as $matchingCommand) { + if (!($matchingCommand instanceof RebasableToOtherWorkspaceInterface)) { + throw new \RuntimeException( + 'ERROR: The command ' . get_class($matchingCommand) + . ' does not implement ' . RebasableToOtherWorkspaceInterface::class . '; but it should!' + ); } - ); + + $commandHandlingDependencies->handle($matchingCommand->createCopyForWorkspace($matchingWorkspaceName)); + } // 5) take EVENTS(MATCHING) and apply them to base WS. $this->publishContentStream( @@ -514,7 +512,6 @@ function () use ($matchingCommands, $commandHandlingDependencies, $baseWorkspace ) ); - // 7) apply REMAINING commands to the workspace's new content stream $commandHandlingDependencies->overrideContentStreamId( $command->workspaceName, @@ -549,10 +546,10 @@ function () use ($commandHandlingDependencies, $remainingCommands) { throw $exception; } - // 8) to avoid dangling content streams, we need to remove our temporary content stream (whose events - // have already been published) as well as the old one - $commandHandlingDependencies->handle(RemoveContentStream::create( - $command->contentStreamIdForMatchingPart + // 8) to avoid dangling content streams, we need to remove our temporary workspace (whose events + // have already been published) as well as the old content stream + $commandHandlingDependencies->handle(DeleteWorkspace::create( + $matchingWorkspaceName )); $commandHandlingDependencies->handle(RemoveContentStream::create( $oldWorkspaceContentStreamId From 4512399629796a69b9a9f997bf49381a4f5cf47b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Mu=CC=88ller?= Date: Sun, 20 Oct 2024 01:25:49 +0200 Subject: [PATCH 3/3] Virtual WorkspaceName light --- .../Classes/ContentRepositoryReadModel.php | 4 ++ .../Feature/WorkspaceCommandHandler.php | 66 +++++++++---------- .../SharedModel/Workspace/WorkspaceName.php | 28 ++++++++ .../CatchUpHook/AssetUsageCatchUpHook.php | 6 ++ 4 files changed, 71 insertions(+), 33 deletions(-) diff --git a/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php b/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php index 38733eaf7a7..d1f3190d1e8 100644 --- a/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php +++ b/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php @@ -68,6 +68,10 @@ public function findContentStreams(): ContentStreams */ public function getContentGraphByWorkspaceName(WorkspaceName $workspaceName): ContentGraphInterface { + if ($workspaceName->isVirtual() && $workspaceName->virtualContentStreamId()) { + return $this->adapter->buildContentGraph($workspaceName, $workspaceName->virtualContentStreamId()); + } + $workspace = $this->findWorkspaceByName($workspaceName); if ($workspace === null) { throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php index 16adec683e7..21cbd9006ed 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php @@ -69,9 +69,7 @@ use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceHasNoBaseWorkspaceName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceDescription; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceTitle; use Neos\EventStore\EventStoreInterface; use Neos\EventStore\Exception\ConcurrencyException; use Neos\EventStore\Model\Event\EventType; @@ -473,14 +471,11 @@ private function handlePublishIndividualNodesFromWorkspace( /** @var array $remainingCommands */ // 3) fork a temporary workspace, based on the base WS, and apply MATCHING - $matchingWorkspaceName = WorkspaceName::transliterateFromString('tmp' . str_replace('-', '', $command->contentStreamIdForMatchingPart->value)); + $matchingWorkspaceName = WorkspaceName::virtualFromContentStreamId($command->contentStreamIdForMatchingPart); $commandHandlingDependencies->handle( - CreateWorkspace::create( - $matchingWorkspaceName, - $baseWorkspace->workspaceName, - WorkspaceTitle::fromString('tmp'), - WorkspaceDescription::fromString(''), - $command->contentStreamIdForMatchingPart + ForkContentStream::create( + $command->contentStreamIdForMatchingPart, + $baseWorkspace->currentContentStreamId ) ); @@ -497,30 +492,36 @@ private function handlePublishIndividualNodesFromWorkspace( $commandHandlingDependencies->handle($matchingCommand->createCopyForWorkspace($matchingWorkspaceName)); } - // 5) take EVENTS(MATCHING) and apply them to base WS. + // 5) publish EVENTS(MATCHING) and apply them to base WS. $this->publishContentStream( $command->contentStreamIdForMatchingPart, $baseWorkspace->workspaceName, $baseWorkspace->currentContentStreamId ); - // 6) fork a new content stream, based on the base WS, and apply REST + // 6) fork a new content stream, based on the base WS and make this the new workspace state $commandHandlingDependencies->handle( - ForkContentStream::create( - $command->contentStreamIdForRemainingPart, - $baseWorkspace->currentContentStreamId - ) + DiscardWorkspace::create($command->workspaceName) + ->withNewContentStreamId($command->contentStreamIdForRemainingPart) ); - // 7) apply REMAINING commands to the workspace's new content stream - $commandHandlingDependencies->overrideContentStreamId( + // 7) fork a new (temporary) content stream, based on the clean WS above, and apply REST + $virtualWorkspaceContentStreamId = ContentStreamId::create(); + $virtualWorkspaceForRemaining = WorkspaceName::virtualFromContentStreamId($virtualWorkspaceContentStreamId); + $commandHandlingDependencies->handle( + ForkContentStream::create($virtualWorkspaceContentStreamId, $command->contentStreamIdForRemainingPart) + ); + + // 8) apply REMAINING commands to the workspace's new content stream + foreach ($remainingCommands as $remainingCommand) { + $commandHandlingDependencies->handle($remainingCommand->createCopyForWorkspace($virtualWorkspaceForRemaining)); + } + + // 9) publish virtual workspace into clean REMAINING workspace + $this->publishContentStream( + $virtualWorkspaceContentStreamId, $command->workspaceName, - $command->contentStreamIdForRemainingPart, - function () use ($commandHandlingDependencies, $remainingCommands) { - foreach ($remainingCommands as $remainingCommand) { - $commandHandlingDependencies->handle($remainingCommand); - } - } + $command->contentStreamIdForRemainingPart ); } catch (\Exception $exception) { // 4.E) In case of an exception, reopen the old content stream and remove the newly created @@ -530,7 +531,6 @@ function () use ($commandHandlingDependencies, $remainingCommands) { $oldWorkspaceContentStreamIdState, ) ); - $commandHandlingDependencies->handle(RemoveContentStream::create( $command->contentStreamIdForMatchingPart )); @@ -548,25 +548,21 @@ function () use ($commandHandlingDependencies, $remainingCommands) { // 8) to avoid dangling content streams, we need to remove our temporary workspace (whose events // have already been published) as well as the old content stream - $commandHandlingDependencies->handle(DeleteWorkspace::create( - $matchingWorkspaceName + $commandHandlingDependencies->handle(RemoveContentStream::create( + $command->contentStreamIdForMatchingPart )); $commandHandlingDependencies->handle(RemoveContentStream::create( $oldWorkspaceContentStreamId )); + $commandHandlingDependencies->handle(RemoveContentStream::create( + $virtualWorkspaceContentStreamId + )); $streamName = WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(); return new EventsToPublish( $streamName, Events::fromArray([ - new WorkspaceWasPartiallyPublished( - $command->workspaceName, - $baseWorkspace->workspaceName, - $command->contentStreamIdForRemainingPart, - $oldWorkspaceContentStreamId, - $command->nodesToPublish - ) ]), ExpectedVersion::ANY() ); @@ -852,6 +848,10 @@ private function requireWorkspaceToNotExist(WorkspaceName $workspaceName, Comman */ private function requireWorkspace(WorkspaceName $workspaceName, CommandHandlingDependencies $commandHandlingDependencies): Workspace { + if ($workspaceName->isVirtual()) { + throw new \RuntimeException("Required workspace's name cannot be virtual", 1729379383); + } + $workspace = $commandHandlingDependencies->findWorkspaceByName($workspaceName); if (is_null($workspace)) { throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php index 1a5b65999d4..0841a017e9f 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php @@ -28,8 +28,13 @@ final class WorkspaceName implements \JsonSerializable private const PATTERN = '/^[a-z0-9][a-z0-9\-]{0,' . (self::MAX_LENGTH - 1) . '}$/'; + private const VIRTUAL_PREFIX = 'vrt-'; + public const WORKSPACE_NAME_LIVE = 'live'; + /** This is only used for virtual workspaces */ + protected ContentStreamId $contentStreamId; + /** * @var array */ @@ -96,6 +101,29 @@ public static function transliterateFromString(string $name): self return self::fromString($name); } + /** + * @internal + */ + public static function virtualFromContentStreamId(ContentStreamId $contentStreamId): self + { + $instance = self::instance(self::VIRTUAL_PREFIX . md5($contentStreamId->value)); + $instance->contentStreamId = $contentStreamId; + return $instance; + } + + public function isVirtual(): bool + { + return str_starts_with($this->value, self::VIRTUAL_PREFIX); + } + + /** + * @internal + */ + public function virtualContentStreamId(): ?ContentStreamId + { + return $this->contentStreamId ?? null; + } + public function isLive(): bool { return $this->value === self::WORKSPACE_NAME_LIVE; diff --git a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php index e240adb41bc..2c3a6bcd091 100644 --- a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php +++ b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php @@ -49,6 +49,9 @@ public function onBeforeEvent(EventInterface $eventInstance, EventEnvelope $even { if ($eventInstance instanceof EmbedsWorkspaceName && $eventInstance instanceof EmbedsContentStreamId) { // Safeguard for temporary content streams created during partial publish -> We want to skip these events, because their workspace doesn't match current content stream. + if ($eventInstance->getWorkspaceName()->isVirtual()) { + return; + } try { $contentGraph = $this->contentRepository->getContentGraph($eventInstance->getWorkspaceName()); } catch (WorkspaceDoesNotExist) { @@ -70,6 +73,9 @@ public function onAfterEvent(EventInterface $eventInstance, EventEnvelope $event { if ($eventInstance instanceof EmbedsWorkspaceName && $eventInstance instanceof EmbedsContentStreamId) { // Safeguard for temporary content streams created during partial publish -> We want to skip these events, because their workspace doesn't match current content stream. + if ($eventInstance->getWorkspaceName()->isVirtual()) { + return; + } try { $contentGraph = $this->contentRepository->getContentGraph($eventInstance->getWorkspaceName()); } catch (WorkspaceDoesNotExist) {