Skip to content

Commit

Permalink
TASK: Speedup nodedata requests by prefetching all childnodes
Browse files Browse the repository at this point in the history
This way the followup methods that add the childnodes to the resulting
noderesults can directly read the children from the 1st level cache.
The speedup is ~50% (1s -> 500ms) in my tests for 175 nodes.
  • Loading branch information
Sebobo committed Jul 4, 2024
1 parent 287e6d5 commit c7637e8
Show file tree
Hide file tree
Showing 2 changed files with 239 additions and 3 deletions.
233 changes: 233 additions & 0 deletions Classes/ContentRepository/Service/NodeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ class NodeService
*/
protected array $contextCache = [];

/**
* @Flow\InjectConfiguration(path="nodeTypeRoles.ignored", package="Neos.Neos.Ui")
* @var string
*/
protected $ignoredNodeTypeRole;

/**
* Helper method to retrieve the closest document for a node
*
Expand Down Expand Up @@ -134,6 +140,233 @@ public function getNodeFromContextPath($contextPath, Site $site = null, Domain $
return $context->getNode($nodePath);
}

/**
* Converts given context paths to a node objects
*
* @param string[] $nodeContextPaths
* @return NodeInterface[]|Error
*/
public function getNodesFromContextPaths(array $nodeContextPaths, Site $site = null, Domain $domain = null, $includeAll = false): array|Error
{
if (!$nodeContextPaths) {
return [];
}

$nodePaths = array_map(static function($nodeContextPath) {
return NodePaths::explodeContextPath($nodeContextPath)['nodePath'];
}, $nodeContextPaths);

$nodePathAndContext = NodePaths::explodeContextPath($nodeContextPaths[0]);
$nodePath = $nodePathAndContext['nodePath'];
$workspaceName = $nodePathAndContext['workspaceName'];
$dimensions = $nodePathAndContext['dimensions'];
$siteNodeName = explode('/', $nodePath)[2];
$contextProperties = $this->prepareContextProperties($workspaceName, $dimensions);

if ($site === null) {
$site = $this->siteRepository->findOneByNodeName($siteNodeName);
}

if ($domain === null) {
$domain = $this->domainRepository->findOneBySite($site);
}

$contextProperties['currentSite'] = $site;
$contextProperties['currentDomain'] = $domain;
if ($includeAll === true) {
$contextProperties['invisibleContentShown'] = true;
$contextProperties['removedContentShown'] = true;
$contextProperties['inaccessibleContentShown'] = true;
}
$context = $this->contextFactory->create($contextProperties);

$workspace = $context->getWorkspace(false);
if (!$workspace) {
return new Error(
sprintf('Could not convert the given source to Node object because the workspace "%s" as specified in the context node path does not exist.', $workspaceName),
1451392329
);
}

// Query nodes and their variants from the database
$queryBuilder = $this->entityManager->createQueryBuilder();
$workspaces = $this->collectWorkspaceAndAllBaseWorkspaces($workspace);
$workspacesNames = array_map(static function(Workspace $workspace) { return $workspace->getName(); }, $workspaces);

// Filter by workspace and its parents
$queryBuilder->select('n')
->from(NodeData::class, 'n')
->where('n.workspace IN (:workspaces)')
->andWhere('n.movedTo IS NULL')
->andWhere('n.path IN (:nodePaths)')
->setParameter('workspaces', $workspacesNames)
->setParameter('nodePaths', $nodePaths);
$query = $queryBuilder->getQuery();
$nodeDataWithVariants = $query->getResult();

// Remove node duplicates
$reducedNodeData = $this->reduceNodeVariantsByWorkspacesAndDimensions($nodeDataWithVariants, $workspaces, $dimensions);

// Convert nodedata objects to nodes
return array_reduce($reducedNodeData, function (array $carry, NodeData $nodeData) use ($context) {
$node = $this->nodeFactory->createFromNodeData($nodeData, $context);
if ($node !== null) {
$carry[] = $node;
}
$context->getFirstLevelNodeCache()->setByPath($node->getPath(), $node);
return $carry;
}, []);
}

/**
* @param NodeInterface[] $parentNodes
*/
public function preloadChildNodesForNodes(array $parentNodes): void
{
if (empty($parentNodes)) {
return;
}

$workspace = $parentNodes[0]->getWorkspace();
$context = $parentNodes[0]->getContext();
$dimensions = $context->getDimensions();

$parentPaths = array_map(static function(NodeInterface $parentNode) {
return $parentNode->getPath();
}, $parentNodes);

// Query nodes and their variants from the database
$queryBuilder = $this->entityManager->createQueryBuilder();
$workspaces = $this->collectWorkspaceAndAllBaseWorkspaces($workspace);
$workspacesNames = array_map(static function(Workspace $workspace) { return $workspace->getName(); }, $workspaces);

// Filter by workspace and its parents
$queryBuilder->select('n')
->from(NodeData::class, 'n')
->where('n.workspace IN (:workspaces)')
->andWhere('n.movedTo IS NULL')
->andWhere('n.parentPath IN (:parentPaths)')
->setParameter('workspaces', $workspacesNames)
->setParameter('parentPaths', $parentPaths);
$query = $queryBuilder->getQuery();
$nodeDataWithVariants = $query->getResult();

// Remove node duplicates
$reducedNodeData = $this->reduceNodeVariantsByWorkspacesAndDimensions(
$nodeDataWithVariants,
$workspaces,
$dimensions
);

// Convert nodedata objects to nodes and group them by parent path
$childNodesByParentPath = array_reduce($reducedNodeData, function (array $carry, NodeData $nodeData) use ($context) {
$node = $this->nodeFactory->createFromNodeData($nodeData, $context);
if ($node !== null) {
if (!isset($carry[$node->getParentPath()])) {
$carry[$node->getParentPath()] = [$node];
} else {
$carry[$node->getParentPath()][] = $node;
}
}
return $carry;
}, []);

foreach ($childNodesByParentPath as $parentPath => $childNodes) {
usort($childNodes, static function(NodeInterface $a, NodeInterface $b) {
return $a->getIndex() <=> $b->getIndex();
});
$context->getFirstLevelNodeCache()->setChildNodesByPathAndNodeTypeFilter(
$parentPath, '!' .
$this->ignoredNodeTypeRole,
$childNodes
);
}
}

/**
* Given an array with duplicate nodes (from different workspaces and dimensions) those are reduced to uniqueness (by node identifier)
* Copied from Neos\ContentRepository\Domain\Repository\NodeDataRepository
*
* @param NodeData[] $nodes NodeData result with multiple and duplicate identifiers (different nodes and redundant results for node variants with different dimensions)
* @param Workspace[] $workspaces
* @param array $dimensions
* @return NodeData[] Array of unique node results indexed by identifier
*/
protected function reduceNodeVariantsByWorkspacesAndDimensions(array $nodes, array $workspaces, array $dimensions): array
{
$reducedNodes = [];

$minimalDimensionPositionsByIdentifier = [];

$workspaceNames = array_map(static fn (Workspace $workspace) => $workspace->getName(), $workspaces);

foreach ($nodes as $node) {
$nodeDimensions = $node->getDimensionValues();

// Find the position of the workspace, a smaller value means more priority
$workspacePosition = array_search($node->getWorkspace()->getName(), $workspaceNames);
if ($workspacePosition === false) {
throw new \Exception(sprintf(
'Node workspace "%s" not found in allowed workspaces (%s), this could result from a detached workspace entity in the context.',
$node->getWorkspace()->getName(),
implode(', ', $workspaceNames)
), 1718740117);
}

// Find positions in dimensions, add workspace in front for highest priority
$dimensionPositions = [];

// Special case for no dimensions
if ($dimensions === []) {
// We can just decide if the given node has no dimensions.
$dimensionPositions[] = ($nodeDimensions === []) ? 0 : 1;
}

foreach ($dimensions as $dimensionName => $dimensionValues) {
if (isset($nodeDimensions[$dimensionName])) {
foreach ($nodeDimensions[$dimensionName] as $nodeDimensionValue) {
$position = array_search($nodeDimensionValue, $dimensionValues);
if ($position === false) {
$position = PHP_INT_MAX;
}
$dimensionPositions[$dimensionName] = isset($dimensionPositions[$dimensionName]) ? min(
$dimensionPositions[$dimensionName],
$position
) : $position;
}
} else {
$dimensionPositions[$dimensionName] = isset($dimensionPositions[$dimensionName]) ? min(
$dimensionPositions[$dimensionName],
PHP_INT_MAX
) : PHP_INT_MAX;
}
}
$dimensionPositions[] = $workspacePosition;

$identifier = $node->getIdentifier();
// Yes, it seems to work comparing arrays that way!
if (!isset($minimalDimensionPositionsByIdentifier[$identifier]) || $dimensionPositions < $minimalDimensionPositionsByIdentifier[$identifier]) {
$reducedNodes[$identifier] = $node;
$minimalDimensionPositionsByIdentifier[$identifier] = $dimensionPositions;
}
}

return $reducedNodes;
}

/**
* @return Workspace[]
*/
protected function collectWorkspaceAndAllBaseWorkspaces(Workspace $workspace): array
{
$workspaces = [];
while ($workspace !== null) {
$workspaces[] = $workspace;
$workspace = $workspace->getBaseWorkspace();
}
return $workspaces;
}

/**
* Checks if the given node exists in the given workspace
*
Expand Down
9 changes: 6 additions & 3 deletions Classes/Controller/BackendServiceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -545,25 +545,28 @@ function ($contextPath) {
$flowQuery = call_user_func_array([$flowQuery, $operation['type']], $operation['payload']);
}

$nodes = array_filter($flowQuery->get());
$this->nodeService->preloadChildNodesForNodes(array_values($nodes));

$nodeInfoHelper = new NodeInfoHelper();
$result = [];
switch ($finisher['type']) {
case 'get':
$result = $nodeInfoHelper->renderNodes(
array_filter($flowQuery->get()),
$nodes,
$this->getControllerContext()
);
break;
case 'getForTree':
$result = $nodeInfoHelper->renderNodes(
array_filter($flowQuery->get()),
$nodes,
$this->getControllerContext(),
true
);
break;
case 'getForTreeWithParents':
$result = $nodeInfoHelper->renderNodesWithParents(
array_filter($flowQuery->get()),
$nodes,
$this->getControllerContext()
);
break;
Expand Down

0 comments on commit c7637e8

Please sign in to comment.