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

BUGFIX: Ensure users content stream is never left closed after publication #5342

Merged
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
90f2b8f
BUGFIX: Ensure all events are published BEFORE catchup
mhsdesign Nov 4, 2024
6fc43ab
Merge remote-tracking branch 'origin/9.0' into bugfix/publishing-ensu…
mhsdesign Nov 9, 2024
4352b2e
BUGFIX: Fix reopen content stream if base workspace was written to du…
mhsdesign Nov 9, 2024
18072f6
TASK: Fix parallel tests by ensuring only one is run at time
mhsdesign Nov 9, 2024
9a6127b
TASK: Simplify code and remove reopen cs logic into `publishWorkspace`
mhsdesign Nov 9, 2024
cb34618
TASK: Only fetch content stream once for constraint checks
mhsdesign Nov 9, 2024
0273e32
TASK: Adjust .composer json
mhsdesign Nov 9, 2024
77778f9
TASK: Do not send `$commitResult` to generator but calculate expected…
mhsdesign Nov 9, 2024
7b922bf
TASK: Inline now simplified `YieldedEventsToPublish` virtual type again
mhsdesign Nov 9, 2024
d27f83f
TASK: Wrap rebaseable command extraction into `finally` block to ensu…
mhsdesign Nov 9, 2024
de7895e
TASK: Close content stream a bit later instead of having to reopen in…
mhsdesign Nov 9, 2024
d290047
TASK: Add proper docs to `EventPersister`
mhsdesign Nov 9, 2024
8e48e7e
TASK: Adjust naming of `removeContentStreamWithoutConstraintChecks`
mhsdesign Nov 9, 2024
59fa2e3
TASK: Improve assertions of WorkspacePublicationDuringWritingTest
mhsdesign Nov 10, 2024
48e09cb
TASK: Assert that in WorkspaceWritingDuringRebaseTest that the worksp…
mhsdesign Nov 10, 2024
e12c641
TASK: Naming things and suggestion from code review :)
mhsdesign Nov 10, 2024
b857367
Merge remote-tracking branch 'origin/9.0' into bugfix/publishing-ensu…
mhsdesign Nov 12, 2024
f883e65
TASK: Fix tests after bastis command test overhaul
mhsdesign Nov 12, 2024
dfd4573
TASK: Recorrect naming of method again (my code editor seems to misbe…
mhsdesign Nov 12, 2024
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
3 changes: 2 additions & 1 deletion .composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@
"../../bin/phpunit --colors --stop-on-failure -c ../../Build/BuildEssentials/PhpUnit/UnitTests.xml Neos.ContentRepository.Core/Tests/Unit",
"../../bin/phpunit --colors --stop-on-failure -c ../../Build/BuildEssentials/PhpUnit/UnitTests.xml Neos.ContentRepositoryRegistry/Tests/Unit"
],
"test:paratest-cli": "../../bin/paratest --debug -v --functional --processes 2 --colors --stop-on-failure -c ../../Build/BuildEssentials/PhpUnit/FunctionalTests.xml",
"test:parallel": [
"FLOW_CONTEXT=Testing/Behat ../../bin/paratest --debug -v --functional --group parallel --processes 2 --colors --stop-on-failure -c ../../Build/BuildEssentials/PhpUnit/FunctionalTests.xml Neos.ContentRepository.BehavioralTests/Tests/Functional/Feature/WorkspacePublication/WorkspaceWritingDuringPublication.php"
"for f in Neos.ContentRepository.BehavioralTests/Tests/Parallel/**/*Test.php; do composer test:paratest-cli $f; done"
bwaidelich marked this conversation as resolved.
Show resolved Hide resolved
],
"test:behat-cli": "../../bin/behat -f progress --strict --no-interaction",
"test:behavioral": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,15 @@ final protected function awaitFile(string $filename): void
}
}

final protected function awaitSharedLock($resource, int $maximumCycles = 2000): void
final protected function awaitFileRemoval(string $filename): void
{
$waiting = 0;
while (!flock($resource, LOCK_SH)) {
usleep(10000);
while (!is_file($filename)) {
usleep(1000);
$waiting++;
if ($waiting > $maximumCycles) {
throw new \Exception('timeout while waiting on shared lock');
clearstatcache(true, $filename);
if ($waiting > 60000) {
throw new \Exception('timeout while waiting on file ' . $filename);
bwaidelich marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
<?php

/*
* This file is part of the Neos.ContentRepository.BehavioralTests 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\BehavioralTests\Tests\Parallel\WorkspacePublicationDuringWriting;

use Neos\ContentRepository\BehavioralTests\Tests\Parallel\AbstractParallelTestCase;
use Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\GherkinPyStringNodeBasedNodeTypeManagerFactory;
use Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\GherkinTableNodeBasedContentDimensionSourceFactory;
use Neos\ContentRepository\Core\ContentRepository;
use Neos\ContentRepository\Core\Dimension\ContentDimension;
use Neos\ContentRepository\Core\Dimension\ContentDimensionId;
use Neos\ContentRepository\Core\Dimension\ContentDimensionSourceInterface;
use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint;
use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint;
use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode;
use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties;
use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite;
use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\CreateRootNodeAggregateWithNode;
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace;
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace;
use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace;
use Neos\ContentRepository\Core\NodeType\NodeTypeManager;
use Neos\ContentRepository\Core\NodeType\NodeTypeName;
use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamIsClosed;
use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId;
use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId;
use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
use Neos\EventStore\Exception\ConcurrencyException;
use Neos\Flow\ObjectManagement\ObjectManagerInterface;
use PHPUnit\Framework\Assert;

class WorkspacePublicationDuringWritingTest extends AbstractParallelTestCase
{
private const SETUP_LOCK_PATH = __DIR__ . '/setup-lock';
private const WRITING_IS_RUNNING_FLAG_PATH = __DIR__ . '/write-is-running-flag';

private ContentRepository $contentRepository;

protected ObjectManagerInterface $objectManager;

public function setUp(): void
{
parent::setUp();
$this->log('------ process started ------');
// todo refrain from Gherkin naming here and make fakes easier to use: https://github.com/neos/neos-development-collection/pull/5346
GherkinTableNodeBasedContentDimensionSourceFactory::$contentDimensionsToUse = new class implements ContentDimensionSourceInterface
{
public function getDimension(ContentDimensionId $dimensionId): ?ContentDimension
{
return null;
}
public function getContentDimensionsOrderedByPriority(): array
{
return [];
}
};
// todo refrain from Gherkin naming here and make fakes easier to use: https://github.com/neos/neos-development-collection/pull/5346
GherkinPyStringNodeBasedNodeTypeManagerFactory::$nodeTypesToUse = new NodeTypeManager(
fn (): array => [
'Neos.ContentRepository:Root' => [],
'Neos.ContentRepository.Testing:Document' => [
'properties' => [
'title' => [
'type' => 'string'
]
]
]
]
);

$setupLockResource = fopen(self::SETUP_LOCK_PATH, 'w+');

$exclusiveNonBlockingLockResult = flock($setupLockResource, LOCK_EX | LOCK_NB);
if ($exclusiveNonBlockingLockResult === false) {
$this->log('waiting for setup');
if (!flock($setupLockResource, LOCK_SH)) {
throw new \RuntimeException('failed to acquire blocking shared lock');
}
$this->contentRepository = $this->contentRepositoryRegistry
->get(ContentRepositoryId::fromString('test_parallel'));
$this->log('wait for setup finished');
return;
}

$this->log('setup started');
$contentRepository = $this->setUpContentRepository(ContentRepositoryId::fromString('test_parallel'));

$origin = OriginDimensionSpacePoint::createWithoutDimensions();
$contentRepository->handle(CreateRootWorkspace::create(
WorkspaceName::forLive(),
ContentStreamId::fromString('live-cs-id')
));
$contentRepository->handle(CreateRootNodeAggregateWithNode::create(
WorkspaceName::forLive(),
NodeAggregateId::fromString('lady-eleonode-rootford'),
NodeTypeName::fromString(NodeTypeName::ROOT_NODE_TYPE_NAME)
));
$contentRepository->handle(CreateNodeAggregateWithNode::create(
WorkspaceName::forLive(),
NodeAggregateId::fromString('nody-mc-nodeface'),
NodeTypeName::fromString('Neos.ContentRepository.Testing:Document'),
$origin,
NodeAggregateId::fromString('lady-eleonode-rootford'),
initialPropertyValues: PropertyValuesToWrite::fromArray([
'title' => 'title-original'
])
));
$contentRepository->handle(CreateWorkspace::create(
WorkspaceName::fromString('user-test'),
WorkspaceName::forLive(),
ContentStreamId::fromString('user-cs-id')
));
for ($i = 0; $i <= 5000; $i++) {
$contentRepository->handle(CreateNodeAggregateWithNode::create(
WorkspaceName::fromString('user-test'),
NodeAggregateId::fromString('nody-mc-nodeface-' . $i),
NodeTypeName::fromString('Neos.ContentRepository.Testing:Document'),
$origin,
NodeAggregateId::fromString('lady-eleonode-rootford'),
initialPropertyValues: PropertyValuesToWrite::fromArray([
'title' => 'title'
])
));
}
$this->contentRepository = $contentRepository;

if (!flock($setupLockResource, LOCK_UN)) {
throw new \RuntimeException('failed to release setup lock');
}

$this->log('setup finished');
}

/**
* @test
* @group parallel
*/
public function whileANodesArWrittenOnLive(): void
{
$this->log('writing started');

touch(self::WRITING_IS_RUNNING_FLAG_PATH);

try {
for ($i = 0; $i <= 50; $i++) {
$this->contentRepository->handle(
SetNodeProperties::create(
WorkspaceName::forLive(),
NodeAggregateId::fromString('nody-mc-nodeface'),
OriginDimensionSpacePoint::createWithoutDimensions(),
PropertyValuesToWrite::fromArray([
'title' => 'changed-title-' . $i
])
)
);
}
} finally {
unlink(self::WRITING_IS_RUNNING_FLAG_PATH);
}

$this->log('writing finished');
Assert::assertTrue(true, 'No exception was thrown ;)');
}

/**
* @test
* @group parallel
*/
public function thenConcurrentPublishLeadsToException(): void
{
if (!is_file(self::WRITING_IS_RUNNING_FLAG_PATH)) {
$this->log('waiting to publish');

$this->awaitFile(self::WRITING_IS_RUNNING_FLAG_PATH);
// If write is the process that does the (slowish) setup, and then waits for the rebase to start,
// We give the CR some time to close the content stream
// TODO find another way than to randomly wait!!!
// The problem is, if we dont sleep it happens often that the modification works only then the rebase is startet _really_
// Doing the modification several times in hope that the second one fails will likely just stop the rebase thread as it cannot close
usleep(10000);
}

$this->log('publish started');


/*
// NOTE, can also be tested with PartialPublish, or PartialPublish leading to a full publish, but this test only allows one at time :)

$nodesForAFullPublish = 5000;
$nodesForAPartialPublish = $nodesForAFullPublish - 1;

$nodeIdToPublish = [];
for ($i = 0; $i <= $nodesForAPartialPublish; $i++) {
$nodeIdToPublish[] = new NodeIdToPublishOrDiscard(
NodeAggregateId::fromString('nody-mc-nodeface-' . $i), // see nodes created above
DimensionSpacePoint::createWithoutDimensions()
);
}

$this->contentRepository->handle(PublishIndividualNodesFromWorkspace::create(
WorkspaceName::fromString('user-test'),
NodeIdsToPublishOrDiscard::create(...$nodeIdToPublish)
));
*/

$actualException = null;
try {
$this->contentRepository->handle(PublishWorkspace::create(
WorkspaceName::fromString('user-test')
));
} catch (\Exception $thrownException) {
$actualException = $thrownException;
}

$this->log('publish finished');

if ($actualException === null) {
Assert::fail(sprintf('No exception was thrown'));
}

if ($actualException instanceof \RuntimeException && $actualException->getCode() === 1652279016) {
// todo can be removed soon
$this->log(sprintf('got expected RuntimeException exception: %s', $actualException->getMessage()));
} elseif ($actualException instanceof ConcurrencyException) {
$this->log(sprintf('got expected ConcurrencyException exception: %s', $actualException->getMessage()));
} else {
Assert::assertInstanceOf(ConcurrencyException::class, $actualException);
}

$this->awaitFileRemoval(self::WRITING_IS_RUNNING_FLAG_PATH);

// just to make sure were up-to-date now!
$this->contentRepository->catchupProjections();

// writing to user works!!!
try {
$this->contentRepository->handle(
SetNodeProperties::create(
WorkspaceName::fromString('user-test'),
NodeAggregateId::fromString('nody-mc-nodeface'),
OriginDimensionSpacePoint::createWithoutDimensions(),
PropertyValuesToWrite::fromArray([
'title' => 'written-after-failed-publish'
])
)
);
} catch (ContentStreamIsClosed $exception) {
Assert::fail(sprintf('Workspace that failed to be publish cannot be written: %s', $exception->getMessage()));
}

$node = $this->contentRepository->getContentGraph(WorkspaceName::fromString('user-test'))
->getSubgraph(DimensionSpacePoint::createWithoutDimensions(), VisibilityConstraints::withoutRestrictions())
->findNodeById(NodeAggregateId::fromString('nody-mc-nodeface'));

Assert::assertSame('written-after-failed-publish', $node?->getProperty('title'));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
use Neos\Flow\ObjectManagement\ObjectManagerInterface;
use PHPUnit\Framework\Assert;

class WorkspaceWritingDuringRebase extends AbstractParallelTestCase
class WorkspaceWritingDuringRebaseTest extends AbstractParallelTestCase

{
private const SETUP_LOCK_PATH = __DIR__ . '/setup-lock';
Expand Down Expand Up @@ -88,7 +88,9 @@ public function getContentDimensionsOrderedByPriority(): array
$exclusiveNonBlockingLockResult = flock($setupLockResource, LOCK_EX | LOCK_NB);
if ($exclusiveNonBlockingLockResult === false) {
$this->log('waiting for setup');
$this->awaitSharedLock($setupLockResource);
if (!flock($setupLockResource, LOCK_SH)) {
throw new \RuntimeException('failed to acquire blocking shared lock');
}
$this->contentRepository = $this->contentRepositoryRegistry
->get(ContentRepositoryId::fromString('test_parallel'));
$this->log('wait for setup finished');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ public function __construct(
}

/**
* The handler only calculate which events they want to have published,
* but do not do the publishing themselves
*
* @return EventsToPublish|\Generator<int, EventsToPublish>
*/
public function handle(CommandInterface $command): EventsToPublish|\Generator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface;
use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface;
use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamDoesNotExistYet;
use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist;
use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId;
use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace;
Expand Down Expand Up @@ -48,11 +49,17 @@ public function contentStreamExists(ContentStreamId $contentStreamId): bool
return $this->contentGraphReadModel->findContentStreamById($contentStreamId) !== null;
}

/**
* @throws ContentStreamDoesNotExistYet if there is no matching content stream
*/
public function isContentStreamClosed(ContentStreamId $contentStreamId): bool
{
$contentStream = $this->contentGraphReadModel->findContentStreamById($contentStreamId);
if ($contentStream === null) {
throw new \InvalidArgumentException(sprintf('Failed to find content stream with id "%s"', $contentStreamId->value), 1729863973);
throw new ContentStreamDoesNotExistYet(
'Content stream "' . $contentStreamId->value . '" does not exist.',
1521386692
);
}
return $contentStream->isClosed;
}
Expand Down
Loading
Loading