From 8d3f9838cc98d51374b1e2054b3b7ef7ef2062ce Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:15:54 +0200 Subject: [PATCH 1/3] WIP FEATURE Buffered forks --- .../Features/Bootstrap/FeatureContext.php | 4 +- .../Features/Bootstrap/ForkBufferTrait.php | 54 ++++++ ...ectionIntegrityViolationDetectionTrait.php | 4 - ...ForkContentStreamWithoutDimensions.feature | 165 ++++++++++++++++++ .../src/ContentStreamForkBufferService.php | 111 ++++++++++++ .../ContentStreamForkBufferServiceFactory.php | 29 +++ .../DoctrineDbalContentGraphProjection.php | 63 +++---- .../DoctrineDbalContentGraphSchemaBuilder.php | 4 +- .../Feature/ContentStreamForking.php | 47 +++++ .../Behavior/Bootstrap/FeatureContext.php | 1 - ...ForkContentStreamWithoutDimensions.feature | 55 +++--- 11 files changed, 463 insertions(+), 74 deletions(-) create mode 100644 Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/ForkBufferTrait.php create mode 100644 Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Projection/ForkBuffer/ForkContentStreamWithoutDimensions.feature create mode 100644 Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferService.php create mode 100644 Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferServiceFactory.php create mode 100644 Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/ContentStreamForking.php diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/FeatureContext.php b/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/FeatureContext.php index 5f2d12f8585..890d72274df 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/FeatureContext.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/FeatureContext.php @@ -11,12 +11,9 @@ * source code. */ -require_once(__DIR__ . '/ProjectionIntegrityViolationDetectionTrait.php'); - use Behat\Behat\Context\Context as BehatContext; use Behat\Behat\Hook\Scope\BeforeScenarioScope; use Neos\Behat\FlowBootstrapTrait; -use Neos\ContentGraph\DoctrineDbalAdapter\Tests\Behavior\Features\Bootstrap\ProjectionIntegrityViolationDetectionTrait; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryInterface; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceInterface; @@ -34,6 +31,7 @@ class FeatureContext implements BehatContext { use FlowBootstrapTrait; use ProjectionIntegrityViolationDetectionTrait; + use ForkBufferTrait; use CRTestSuiteTrait; use CRBehavioralTestsSubjectProvider; diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/ForkBufferTrait.php b/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/ForkBufferTrait.php new file mode 100644 index 00000000000..34ff4f23400 --- /dev/null +++ b/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/ForkBufferTrait.php @@ -0,0 +1,54 @@ + $className + * + * @return T + */ + abstract private function getObject(string $className): object; + + public function getContentStreamForkBufferService(): ContentStreamForkBufferService + { + return $this->getContentRepositoryService( + new ContentStreamForkBufferServiceFactory( + $this->getObject(Connection::class) + ) + ); + } + + /** + * @Then I expect :number buffered forks for content stream :contentStreamId + */ + public function iExpectNumberOfBufferedForksForContentStreamId(int $number, string $contentStreamId): void + { + Assert::assertSame( + $number, + $this->getContentStreamForkBufferService()->countBufferForksByContentStreamId( + ContentStreamId::fromString($contentStreamId) + ) + ); + } + + /** + * @When I create :number buffered forks for content stream :contentStreamId + */ + public function iCreateNumberOfBufferedForksForContentStreamId(int $number, string $contentStreamId): void + { + $this->getContentStreamForkBufferService()->preForkContentStreamId( + ContentStreamId::fromString($contentStreamId), + $number + ); + } +} diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/ProjectionIntegrityViolationDetectionTrait.php b/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/ProjectionIntegrityViolationDetectionTrait.php index eaf9aea1dde..b8e61264b55 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/ProjectionIntegrityViolationDetectionTrait.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/ProjectionIntegrityViolationDetectionTrait.php @@ -12,8 +12,6 @@ declare(strict_types=1); -namespace Neos\ContentGraph\DoctrineDbalAdapter\Tests\Behavior\Features\Bootstrap; - use Behat\Gherkin\Node\TableNode; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception as DBALException; @@ -35,8 +33,6 @@ /** * Custom context trait for projection integrity violation detection specific to the Doctrine DBAL content graph adapter - * - * @todo move this class somewhere where its autoloaded */ trait ProjectionIntegrityViolationDetectionTrait { diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Projection/ForkBuffer/ForkContentStreamWithoutDimensions.feature b/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Projection/ForkBuffer/ForkContentStreamWithoutDimensions.feature new file mode 100644 index 00000000000..39a5f40038d --- /dev/null +++ b/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Projection/ForkBuffer/ForkContentStreamWithoutDimensions.feature @@ -0,0 +1,165 @@ +Feature: + Background: + Given using no content dimensions + And using the following node types: + """yaml + Neos.ContentRepository:Root: {} + '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 I am in workspace "live" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nody-mc-nodeface" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | originDimensionSpacePoint | {} | + | parentNodeAggregateId | "lady-eleonode-rootford" | + | nodeName | "child" | + | nodeAggregateClassification | "regular" | + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "original value"} | + | propertiesToUnset | {} | + + # initial state + Then I expect 0 buffered forks for content stream "cs-identifier" + + Scenario: Single buffered fork without changes + When I create 1 buffered forks for content stream "cs-identifier" + Then I expect 1 buffered forks for content stream "cs-identifier" + + # Uses buffered implicitly + When the command CreateWorkspace is executed with payload: + | Key | Value | + | baseWorkspaceName | "live" | + | workspaceName | "user-test" | + | newContentStreamId | "user-cs-identifier" | + + Then I expect 0 buffered forks for content stream "cs-identifier" + + When I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} + + Scenario: Multiple buffered forks without changes + When I create 2 buffered forks for content stream "cs-identifier" + Then I expect 2 buffered forks for content stream "cs-identifier" + + # Uses buffered implicitly + When the command CreateWorkspace is executed with payload: + | Key | Value | + | baseWorkspaceName | "live" | + | workspaceName | "user-test" | + | newContentStreamId | "user-cs-identifier" | + Then I expect 1 buffered forks for content stream "cs-identifier" + + When I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} + + # Uses buffered implicitly + When the command CreateWorkspace is executed with payload: + | Key | Value | + | baseWorkspaceName | "live" | + | workspaceName | "user-2-test" | + | newContentStreamId | "user-second-cs-identifier" | + Then I expect 0 buffered forks for content stream "cs-identifier" + + When I am in workspace "user-2-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-second-cs-identifier;nody-mc-nodeface;{} + + Scenario: Refill buffered forks without changes + When I create 1 buffered forks for content stream "cs-identifier" + Then I expect 1 buffered forks for content stream "cs-identifier" + When I create 1 buffered forks for content stream "cs-identifier" + Then I expect 2 buffered forks for content stream "cs-identifier" + + # Uses buffered implicitly + When the command CreateWorkspace is executed with payload: + | Key | Value | + | baseWorkspaceName | "live" | + | workspaceName | "user-test" | + | newContentStreamId | "user-cs-identifier" | + Then I expect 1 buffered forks for content stream "cs-identifier" + + When I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} + + # only if the force flag is used we enforce a fork: + When the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | rebasedContentStreamId | "user-cs-rebased" | + | rebaseErrorHandlingStrategy | "force" | + Then I expect 0 buffered forks for content stream "cs-identifier" + + When I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-rebased;nody-mc-nodeface;{} + + Scenario: When a change is applied on the live content stream AFTER the buffered fork, buffered forks will be fast forwarded + When I create 1 buffered forks for content stream "cs-identifier" + Then I expect 1 buffered forks for content stream "cs-identifier" + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "modified value"} | + | propertiesToUnset | {} | + + # must be fast forward + Then I expect 1 buffered forks for content stream "cs-identifier" + + When the command CreateWorkspace is executed with payload: + | Key | Value | + | baseWorkspaceName | "live" | + | workspaceName | "user-test" | + | newContentStreamId | "user-cs-identifier" | + + # buffer used for forking + Then I expect 0 buffered forks for content stream "cs-identifier" + + # forked content stream + When I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} + And I expect this node to have the following properties: + | Key | Value | + | text | "modified value" | + + # write to user content stream + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "modified value 2"} | + | propertiesToUnset | {} | + + # live has original value + When I am in workspace "live" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node cs-identifier;nody-mc-nodeface;{} + And I expect this node to have the following properties: + | Key | Value | + | text | "modified value" | + +# Todo test that after replay there are no buffered forks +# Test that buffered forks are used during publishing and all other forks +# Test that buffered forks also work on non root workspaces? +# !!! TODO MUST be thread safe and lock other cr actions like a fork triggered by user :OOO diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferService.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferService.php new file mode 100644 index 00000000000..fd91b32fd6e --- /dev/null +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferService.php @@ -0,0 +1,111 @@ +tableNames->contentStream()} as cs + WHERE + cs.id IS NULL + AND cs.sourceContentStreamId = :contentStreamId + SQL; + try { + $row = $this->dbal->fetchOne($countBufferForksStatement, [ + 'contentStreamId' => $contentStreamId->value + ]); + } catch (Exception $e) { + throw new \RuntimeException(sprintf('Failed to load number of pre forks from database: %s', $e->getMessage()), 1775201750, $e); + } + return (int)$row; + } + + public function preForkContentStreamId(ContentStreamId $contentStreamId, int $numberOfForks): void + { + if ($numberOfForks <= 0) { + throw new \RuntimeException(sprintf('Number of forks must be positive integer %d', $numberOfForks), 1775205430); + } + + // todo extract into common ContentStream trait? + $selectContentStreamStatement = <<tableNames->contentStream()} + WHERE id = :contentStreamId + LIMIT 1 + SQL; + try { + $contentStreamRow = $this->dbal->fetchAssociative($selectContentStreamStatement, [ + 'contentStreamId' => $contentStreamId->value + ]); + } catch (Exception $e) { + throw new \RuntimeException(sprintf('TODO: %s', $e->getMessage()), 1775201750, $e); + } + if ($contentStreamRow === false) { + throw new \RuntimeException(sprintf('content stream %s does not exist', $contentStreamId->value), 1775203449); + } + + $selectBufferedDbIdsStatement = <<tableNames->contentStream()} + WHERE id IS NULL + AND sourceContentStreamId = :contentStreamId + SQL; + try { + $existingDbIds = $this->dbal->fetchFirstColumn($selectBufferedDbIdsStatement, [ + 'contentStreamId' => $contentStreamId->value, + ]); + } catch (Exception $e) { + throw new \RuntimeException(sprintf('TODO: %s', $e->getMessage()), 1775201750, $e); + } + + for ($i = 0; $i < $numberOfForks; $i++) { + $this->dbal->insert($this->tableNames->contentStream(), [ + 'id' => null, + 'version' => 0, + 'sourceContentStreamId' => $contentStreamId->value, + 'sourceContentStreamVersion' => $contentStreamRow['version'], + 'closed' => 0, + 'hasChanges' => 0, + ]); + } + + try { + $updatedDbIds = $this->dbal->fetchFirstColumn($selectBufferedDbIdsStatement, [ + 'contentStreamId' => $contentStreamId->value, + ]); + } catch (Exception $e) { + throw new \RuntimeException(sprintf('TODO: %s', $e->getMessage()), 1775201750, $e); + } + + $newDbIds = array_diff($updatedDbIds, $existingDbIds); + if (count($newDbIds) !== $numberOfForks) { + throw new \RuntimeException(sprintf('Fatal did not create %d new forks only %d', $numberOfForks, count($newDbIds)), 1775205377); + } + + // TODO Totally unsafe for parallel as we dont use transactions? + foreach ($newDbIds as $dbId) { + $this->copyHierarchyRelations(ContentStreamDbId::fromInt($dbId), ContentStreamDbId::fromInt($contentStreamRow['dbId'])); + } + } +} diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferServiceFactory.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferServiceFactory.php new file mode 100644 index 00000000000..12800789e37 --- /dev/null +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferServiceFactory.php @@ -0,0 +1,29 @@ + + */ +class ContentStreamForkBufferServiceFactory implements ContentRepositoryServiceFactoryInterface +{ + public function __construct( + private readonly Connection $dbal + ) { + } + + public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): ContentRepositoryServiceInterface + { + return new ContentStreamForkBufferService( + ContentGraphTableNames::create($serviceFactoryDependencies->contentRepositoryId), + $this->dbal + ); + } +} diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php index cf76c111067..928a5b0c12f 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php @@ -9,6 +9,7 @@ use Doctrine\DBAL\Exception as DBALException; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\ContentStreamDbId; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\Feature\ContentStream; +use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\Feature\ContentStreamForking; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\Feature\NodeMove; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\Feature\NodeRemoval; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\Feature\NodeVariation; @@ -85,7 +86,7 @@ final class DoctrineDbalContentGraphProjection implements ContentGraphProjection use NodeVariation; use SubtreeTagging; use Workspace; - + use ContentStreamForking; public const RELATION_DEFAULT_OFFSET = 128; @@ -215,45 +216,39 @@ private function whenContentStreamWasCreated(ContentStreamWasCreated $event): vo private function whenContentStreamWasForked(ContentStreamWasForked $event): void { - $this->createContentStream($event->newContentStreamId, $event->sourceContentStreamId, $event->versionOfSourceContentStream); - - $newContentStreamDbId = $this->contentStreamDbIdFinder->getContentStreamDbId($event->newContentStreamId); - $sourceContentStreamDbId = $this->contentStreamDbIdFinder->getContentStreamDbId($event->sourceContentStreamId); - - // - // 1) Copy HIERARCHY RELATIONS (this is the MAIN OPERATION here) - // - $insertRelationStatement = <<tableNames->hierarchyRelation()} ( - parentnodeanchor, - childnodeanchor, - position, - dimensionspacepointhash, - subtreetags, - contentstreamdbid - ) - SELECT - h.parentnodeanchor, - h.childnodeanchor, - h.position, - h.dimensionspacepointhash, - h.subtreetags, - :newContentStreamDbId AS contentstreamdbid - FROM - {$this->tableNames->hierarchyRelation()} h - WHERE h.contentstreamdbid = :sourceContentStreamDbId + $selectBufferedContentStreamStatement = <<tableNames->contentStream()} + WHERE sourceContentStreamId = :contentStreamId + AND id IS NULL + AND sourceContentStreamVersion = :version + LIMIT 1 SQL; try { - $this->dbal->executeStatement($insertRelationStatement, [ - 'newContentStreamDbId' => $newContentStreamDbId->value, - 'sourceContentStreamDbId' => $sourceContentStreamDbId->value + $bufferedSourceContentStreamDbId = $this->dbal->fetchOne($selectBufferedContentStreamStatement, [ + 'contentStreamId' => $event->sourceContentStreamId, + 'version' => $event->versionOfSourceContentStream->value, ]); } catch (DBALException $e) { - throw new \RuntimeException(sprintf('Failed to insert hierarchy relation: %s', $e->getMessage()), 1716489211, $e); + throw new \RuntimeException(sprintf('TODO: %s', $e->getMessage()), 1775201750, $e); + } + if ($bufferedSourceContentStreamDbId !== false) { + // optimized path + $this->dbal->update($this->tableNames->contentStream(), [ + 'id' => $event->newContentStreamId->value, + 'sourceContentStreamId' => null, + ], [ + 'dbId' => $bufferedSourceContentStreamDbId + ]); + return; } - // NOTE: as reference edges are attached to Relation Anchor Points (and they are lazily copy-on-written), - // we do not need to copy reference edges here (but we need to do it during copy on write). + $this->createContentStream($event->newContentStreamId, $event->sourceContentStreamId, $event->versionOfSourceContentStream); + + $newContentStreamDbId = $this->contentStreamDbIdFinder->getContentStreamDbId($event->newContentStreamId); + $sourceContentStreamDbId = $this->contentStreamDbIdFinder->getContentStreamDbId($event->sourceContentStreamId); + + $this->copyHierarchyRelations($newContentStreamDbId, $sourceContentStreamDbId); } private function whenContentStreamWasRemoved(ContentStreamWasRemoved $event): void diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php index 47832978d56..dccb298a63e 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php @@ -121,7 +121,7 @@ private function createContentStreamTable(AbstractPlatform $platform): Table { $contentStreamTable = self::createTable($this->tableNames->contentStream(), [ (new Column('dbId', Type::getType(Types::INTEGER)))->setAutoincrement(true)->setNotnull(true), - DbalSchemaFactory::columnForContentStreamId('id', $platform)->setNotnull(true), + DbalSchemaFactory::columnForContentStreamId('id', $platform)->setNotnull(false), (new Column('version', Type::getType(Types::INTEGER)))->setNotnull(true), DbalSchemaFactory::columnForContentStreamId('sourceContentStreamId', $platform)->setNotnull(false), (new Column('sourceContentStreamVersion', Type::getType(Types::INTEGER)))->setNotnull(false), @@ -130,7 +130,7 @@ private function createContentStreamTable(AbstractPlatform $platform): Table ]); return $contentStreamTable - ->addUniqueIndex(['id']) + ->addIndex(['id']) // todo reintroduce unique index but now the field is nullable :O -> add new table? ->setPrimaryKey(['dbId']); } diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/ContentStreamForking.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/ContentStreamForking.php new file mode 100644 index 00000000000..41d3d4822f7 --- /dev/null +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/ContentStreamForking.php @@ -0,0 +1,47 @@ +tableNames->hierarchyRelation()} ( + parentnodeanchor, + childnodeanchor, + position, + dimensionspacepointhash, + subtreetags, + contentstreamdbid + ) + SELECT + h.parentnodeanchor, + h.childnodeanchor, + h.position, + h.dimensionspacepointhash, + h.subtreetags, + :newContentStreamDbId AS contentstreamdbid + FROM + {$this->tableNames->hierarchyRelation()} h + WHERE h.contentstreamdbid = :sourceContentStreamDbId + SQL; + try { + $this->dbal->executeStatement($insertRelationStatement, [ + 'newContentStreamDbId' => $newContentStreamDbId->value, + 'sourceContentStreamDbId' => $sourceContentStreamDbId->value + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to insert hierarchy relation: %s', $e->getMessage()), 1716489211, $e); + } + + // NOTE: as reference edges are attached to Relation Anchor Points (and they are lazily copy-on-written), + // we do not need to copy reference edges here (but we need to do it during copy on write). + } +} diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Bootstrap/FeatureContext.php b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Bootstrap/FeatureContext.php index a6a4fcbc25d..49058dd5bf7 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Bootstrap/FeatureContext.php +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Bootstrap/FeatureContext.php @@ -19,7 +19,6 @@ use Doctrine\DBAL\Connection; use GuzzleHttp\Psr7\Uri; use Neos\Behat\FlowBootstrapTrait; -use Neos\ContentGraph\DoctrineDbalAdapter\Tests\Behavior\Features\Bootstrap\ProjectionIntegrityViolationDetectionTrait; use Neos\ContentRepository\BehavioralTests\ProjectionRaceConditionTester\Dto\TraceEntryType; use Neos\ContentRepository\BehavioralTests\ProjectionRaceConditionTester\RedisInterleavingLogger; use Neos\ContentRepository\Core\ContentRepository; diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/ForkContentStreamWithoutDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/ForkContentStreamWithoutDimensions.feature index 22570332780..5993681fe6a 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/ForkContentStreamWithoutDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/ForkContentStreamWithoutDimensions.feature @@ -28,26 +28,24 @@ Feature: ForkContentStream Without Dimensions | Key | Value | | nodeAggregateId | "lady-eleonode-rootford" | | nodeTypeName | "Neos.ContentRepository:Root" | - And the event NodeAggregateWithNodeWasCreated was published with payload: + + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | workspaceName | "live" | - | contentStreamId | "cs-identifier" | | nodeAggregateId | "nody-mc-nodeface" | | nodeTypeName | "Neos.ContentRepository.Testing:Content" | | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | | parentNodeAggregateId | "lady-eleonode-rootford" | | nodeName | "child" | | nodeAggregateClassification | "regular" | - And the event NodePropertiesWereSet was published with payload: - | Key | Value | - | workspaceName | "live" | - | contentStreamId | "cs-identifier" | - | nodeAggregateId | "nody-mc-nodeface" | - | originDimensionSpacePoint | {} | - | affectedDimensionSpacePoints | [{}] | - | propertyValues | {"text": {"value": "original value", "type": "string"}} | - | propertiesToUnset | {} | + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "original value"} | + | propertiesToUnset | {} | Scenario: Ensure that the node is available in the forked content stream # Uses ForkContentStream implicitly @@ -68,15 +66,13 @@ Feature: ForkContentStream Without Dimensions | workspaceName | "user-test" | | newContentStreamId | "user-cs-identifier" | - And the event NodePropertiesWereSet was published with payload: - | Key | Value | - | workspaceName | "user-test" | - | contentStreamId | "user-cs-identifier" | - | nodeAggregateId | "nody-mc-nodeface" | - | originDimensionSpacePoint | {} | - | affectedDimensionSpacePoints | [{}] | - | propertyValues | {"text": {"value": "modified value", "type": "string"}} | - | propertiesToUnset | {} | + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "modified value"} | + | propertiesToUnset | {} | # live When I am in workspace "live" and dimension space point {} @@ -100,15 +96,14 @@ Feature: ForkContentStream Without Dimensions | baseWorkspaceName | "live" | | workspaceName | "user-test" | | newContentStreamId | "user-cs-identifier" | - And the event NodePropertiesWereSet was published with payload: - | Key | Value | - | workspaceName | "live" | - | contentStreamId | "cs-identifier" | - | nodeAggregateId | "nody-mc-nodeface" | - | originDimensionSpacePoint | {} | - | affectedDimensionSpacePoints | [{}] | - | propertyValues | {"text": {"value": "modified value", "type": "string"}} | - | propertiesToUnset | {} | + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "modified value"} | + | propertiesToUnset | {} | # live When I am in workspace "live" and dimension space point {} From cbc3b0cd4f199203834812d08f546cfd0b18ac22 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:49:17 +0200 Subject: [PATCH 2/3] WIP FEATURE Fast forward of buffered forks as per #5264 https://github.com/neos/neos-development-collection/issues/5264 shows how this can be achieved. The test fails currently and a possibly fast forwarding has to be tested under many aspects separately! --- .../Features/Bootstrap/ForkBufferTrait.php | 10 ++ ...ForkContentStreamWithoutDimensions.feature | 15 ++- .../src/ContentStreamForkBufferService.php | 120 +++++++++++++++++- 3 files changed, 138 insertions(+), 7 deletions(-) diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/ForkBufferTrait.php b/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/ForkBufferTrait.php index 34ff4f23400..000908b8a8d 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/ForkBufferTrait.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/ForkBufferTrait.php @@ -51,4 +51,14 @@ public function iCreateNumberOfBufferedForksForContentStreamId(int $number, stri $number ); } + + /** + * @When I fast-forward buffered forks for content stream :contentStreamId + */ + public function iFastForwardBufferedForksForContentStreamId(string $contentStreamId): void + { + $this->getContentStreamForkBufferService()->fastForwardForkedContentStreamId( + ContentStreamId::fromString($contentStreamId) + ); + } } diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Projection/ForkBuffer/ForkContentStreamWithoutDimensions.feature b/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Projection/ForkBuffer/ForkContentStreamWithoutDimensions.feature index 39a5f40038d..27b5e4a4396 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Projection/ForkBuffer/ForkContentStreamWithoutDimensions.feature +++ b/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Projection/ForkBuffer/ForkContentStreamWithoutDimensions.feature @@ -116,6 +116,7 @@ Feature: When I create 1 buffered forks for content stream "cs-identifier" Then I expect 1 buffered forks for content stream "cs-identifier" + # changes to live after buffered forks And the command SetNodeProperties is executed with payload: | Key | Value | | workspaceName | "live" | @@ -133,12 +134,22 @@ Feature: | workspaceName | "user-test" | | newContentStreamId | "user-cs-identifier" | - # buffer used for forking + # buffer NOT used for forking, as it is behind + Then I expect 1 buffered forks for content stream "cs-identifier" + + # fast forward and use buffer + When I fast-forward buffered forks for content stream "cs-identifier" + # only if the force flag is used we enforce a fork: + When the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | rebasedContentStreamId | "user-cs-rebased" | + | rebaseErrorHandlingStrategy | "force" | Then I expect 0 buffered forks for content stream "cs-identifier" # forked content stream When I am in workspace "user-test" and dimension space point {} - Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-rebased;nody-mc-nodeface;{} And I expect this node to have the following properties: | Key | Value | | text | "modified value" | diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferService.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferService.php index fd91b32fd6e..9b3f8fe425a 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferService.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferService.php @@ -5,7 +5,7 @@ namespace Neos\ContentGraph\DoctrineDbalAdapter; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Exception; +use Doctrine\DBAL\Exception as DBALException; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\ContentStreamDbId; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\Feature\ContentStreamForking; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceInterface; @@ -35,7 +35,7 @@ public function countBufferForksByContentStreamId(ContentStreamId $contentStream $row = $this->dbal->fetchOne($countBufferForksStatement, [ 'contentStreamId' => $contentStreamId->value ]); - } catch (Exception $e) { + } catch (DBALException $e) { throw new \RuntimeException(sprintf('Failed to load number of pre forks from database: %s', $e->getMessage()), 1775201750, $e); } return (int)$row; @@ -58,7 +58,7 @@ public function preForkContentStreamId(ContentStreamId $contentStreamId, int $nu $contentStreamRow = $this->dbal->fetchAssociative($selectContentStreamStatement, [ 'contentStreamId' => $contentStreamId->value ]); - } catch (Exception $e) { + } catch (DBALException $e) { throw new \RuntimeException(sprintf('TODO: %s', $e->getMessage()), 1775201750, $e); } if ($contentStreamRow === false) { @@ -75,7 +75,7 @@ public function preForkContentStreamId(ContentStreamId $contentStreamId, int $nu $existingDbIds = $this->dbal->fetchFirstColumn($selectBufferedDbIdsStatement, [ 'contentStreamId' => $contentStreamId->value, ]); - } catch (Exception $e) { + } catch (DBALException $e) { throw new \RuntimeException(sprintf('TODO: %s', $e->getMessage()), 1775201750, $e); } @@ -94,7 +94,7 @@ public function preForkContentStreamId(ContentStreamId $contentStreamId, int $nu $updatedDbIds = $this->dbal->fetchFirstColumn($selectBufferedDbIdsStatement, [ 'contentStreamId' => $contentStreamId->value, ]); - } catch (Exception $e) { + } catch (DBALException $e) { throw new \RuntimeException(sprintf('TODO: %s', $e->getMessage()), 1775201750, $e); } @@ -108,4 +108,114 @@ public function preForkContentStreamId(ContentStreamId $contentStreamId, int $nu $this->copyHierarchyRelations(ContentStreamDbId::fromInt($dbId), ContentStreamDbId::fromInt($contentStreamRow['dbId'])); } } + + public function fastForwardForkedContentStreamId(ContentStreamId $contentStreamId): void + { + $selectContentStreamStatement = <<tableNames->contentStream()} + WHERE id = :contentStreamId + LIMIT 1 + SQL; + try { + $contentStreamRow = $this->dbal->fetchAssociative($selectContentStreamStatement, [ + 'contentStreamId' => $contentStreamId->value + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('TODO: %s', $e->getMessage()), 1775201750, $e); + } + if ($contentStreamRow === false) { + throw new \RuntimeException(sprintf('content stream %s does not exist', $contentStreamId->value), 1775203449); + } + + $selectBehindBufferedDbIdsStatement = <<tableNames->contentStream()} + WHERE id IS NULL + AND sourceContentStreamId = :contentStreamId + AND sourceContentStreamVersion != :version + SQL; + try { + $behindBufferedDbIds = $this->dbal->fetchFirstColumn($selectBehindBufferedDbIdsStatement, [ + 'contentStreamId' => $contentStreamId->value, + 'version' => $contentStreamRow['version'] + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('TODO: %s', $e->getMessage()), 1775201750, $e); + } + + $removeStaleExclusiveEdges = <<tableNames->hierarchyRelation()} + WHERE + (dimensionspacepointhash, parentnodeanchor, childnodeanchor, contentstreamdbid) + IN ( + SELECT + dimensionspacepointhash, parentnodeanchor, childnodeanchor, contentstreamdbid + FROM + {$this->tableNames->hierarchyRelation()} h_source + WHERE + h_source.contentstreamdbid = :bufferedDbId + AND NOT EXISTS ( + SELECT + h_target.* + FROM + {$this->tableNames->hierarchyRelation()} h_target + WHERE + h_target.contentstreamdbid = :sourceDbId + AND h_target.dimensionspacepointhash = h_source.dimensionspacepointhash + AND h_target.parentnodeanchor = h_source.parentnodeanchor + AND h_target.childnodeanchor = h_source.childnodeanchor + ) + ); + SQL; + + $copyNewExclusiveEdges = <<tableNames->hierarchyRelation()} (position, dimensionspacepointhash, parentnodeanchor, childnodeanchor, contentstreamdbid, subtreetags) + SELECT + position, dimensionspacepointhash, parentnodeanchor, childnodeanchor, :sourceDbId AS contentstreamdbid, subtreetags + FROM + {$this->tableNames->hierarchyRelation()} h_target + WHERE + h_target.contentstreamdbid = :sourceDbId + AND NOT EXISTS ( + SELECT + h_source.* + FROM + {$this->tableNames->hierarchyRelation()} h_source + WHERE + h_source.contentstreamdbid = :bufferedDbId + AND h_source.dimensionspacepointhash = h_target.dimensionspacepointhash + AND h_source.parentnodeanchor = h_target.parentnodeanchor + AND h_source.childnodeanchor = h_target.childnodeanchor + ); + SQL; + + // todo cleanup old node records + + foreach ($behindBufferedDbIds as $behindBufferedDbId) { + try { + $this->dbal->executeStatement($removeStaleExclusiveEdges, [ + 'bufferedDbId' => $behindBufferedDbId, + 'sourceDbId' => $contentStreamRow['dbId'] + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Todo: %s', $e->getMessage()), 1716489211, $e); + } + try { + $this->dbal->executeStatement($copyNewExclusiveEdges, [ + 'bufferedDbId' => $behindBufferedDbId, + 'sourceDbId' => $contentStreamRow['dbId'] + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Todo: %s', $e->getMessage()), 1716489211, $e); + } + $this->dbal->update($this->tableNames->contentStream(), [ + 'sourceContentStreamVersion' => $contentStreamRow['version'], + ], [ + 'dbId' => $behindBufferedDbId + ]); + } + } } From 831fd9836284e8eaf4146a55c957dab6e07639cd Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:55:00 +0200 Subject: [PATCH 3/3] TASK: Code style and fix sql statement --- .../src/ContentStreamForkBufferService.php | 5 ++++- .../src/ContentStreamForkBufferServiceFactory.php | 1 + .../src/Domain/Projection/Feature/ContentStreamForking.php | 5 +++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferService.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferService.php index 9b3f8fe425a..915cc035638 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferService.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferService.php @@ -11,6 +11,9 @@ use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceInterface; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; +/** + * @api + */ final readonly class ContentStreamForkBufferService implements ContentRepositoryServiceInterface { use ContentStreamForking; @@ -174,7 +177,7 @@ public function fastForwardForkedContentStreamId(ContentStreamId $contentStreamI $copyNewExclusiveEdges = <<tableNames->hierarchyRelation()} (position, dimensionspacepointhash, parentnodeanchor, childnodeanchor, contentstreamdbid, subtreetags) SELECT - position, dimensionspacepointhash, parentnodeanchor, childnodeanchor, :sourceDbId AS contentstreamdbid, subtreetags + position, dimensionspacepointhash, parentnodeanchor, childnodeanchor, :bufferedDbId AS contentstreamdbid, subtreetags FROM {$this->tableNames->hierarchyRelation()} h_target WHERE diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferServiceFactory.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferServiceFactory.php index 12800789e37..9191e118988 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferServiceFactory.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentStreamForkBufferServiceFactory.php @@ -10,6 +10,7 @@ use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceInterface; /** + * @api * @implements ContentRepositoryServiceFactoryInterface */ class ContentStreamForkBufferServiceFactory implements ContentRepositoryServiceFactoryInterface diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/ContentStreamForking.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/ContentStreamForking.php index 41d3d4822f7..3f9e537616c 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/ContentStreamForking.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/ContentStreamForking.php @@ -1,10 +1,15 @@