CatchUp-Hooks

Listen to events and query the projection (e.g. content graph)

Legacy Content Repository: Signals and Slots

The new ESCR doesn't leverage Flow's Framework and thus one cannot connect to Node property changes or creation. These signals from the old CR were removed.

#What are CatchUp-Hooks

The api with which you can hook into the catch-up process of a projection. In event sourcing we use projections which build up a persistent state in the database based on all occurred events and the new events coming in. Projections are a sophisticated extension point and require great knowledge of all events. Especially tracking the hierarchy of Nodes is not handlebar for an own projection without copying the whole Content-Graph-Projection. Instead of creating a new projection we use a hook mechanism to get notified before and after something was changed and are allowed to make queries. With these hooks one can also build up a state in the database or use them as trigger for a queue to send a notification. A CatchUp-Hook can only be registered to a single projection as projections have a decoupled life cycle (can be replayed individually).

#Where are they used within Neos

Most CatchUp-Hooks are listening to changes writing the Content-Graph-Projection:

  • FlushSubgraphCachePoolCatchUpHook
    • Used for simple PHP runtime cache invalidation ie. to flush findNodeById
  • GraphProjectorCatchUpHookForCacheFlushing
    • Enabled Fusion Content Cache invalidation. Determines the NodeType and calculates all parent Node Aggregates to flush by the determined CacheTags
  • AssetUsageCatchUpHook
    • Builds an index of assets used in Node properties and within text.

An example of a CatchUp-Hook on a different projection would be the RouterCacheHook which hooks onto the Document-Uri-Path-Projection to determine which urls should be flushed.

#Similar extension points

CommandHooks
see (Neos\ContentRepository\Core\CommandHandler\CommandHookInterface)

Contract for a hook that is invoked just before any command is processed via ContentRepository::handle()
A command hook can be used to replace/alter an incoming command before it is being passed to the corresponding command handler.
This can be used to change or enrich the payload of the command.
A command hook can also be used to intercept commands based on their type or payload but this is not the intended use case because it can lead to a degraded user experience

Projections
see (Neos\ContentRepository\Core\Projection\ProjectionInterface)

#Create your own CatchUp-Hook

CatchUp-Hooks are build via a factory which provides usually all necessary dependencies. In this case MyService from Flow's object-management and the NodeTypeManager from the argument $dependencies.

php
<?php

declare(strict_types=1);

namespace Vendor\Site;

use Neos\ContentRepository\Core\Projection\CatchUpHook\CatchUpHookFactoryDependencies;
use Neos\ContentRepository\Core\Projection\CatchUpHook\CatchUpHookFactoryInterface;
use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface;

/**
 * @implements CatchUpHookFactoryInterface<ContentGraphReadModelInterface>
 */
class MyCatchUpHookFactory implements CatchUpHookFactoryInterface
{
    public function __construct(
        private MyService $myService
    ) {
    }

    public function build(CatchUpHookFactoryDependencies $dependencies): MyCatchUpHook
    {
        return new MyCatchUpHook(
            $dependencies->contentRepositoryId,
            $dependencies->projectionState,
            $dependencies->nodeTypeManager,
            $this->myService
        );
    }
}

The factory is registered via YAML for the projection of a content repository:

yaml
Neos:
  ContentRepositoryRegistry:
    contentRepositories:
      default:
        contentGraphProjection:
          catchUpHooks:
            'Vendor.Site:MyHook':
              factoryObjectName: Vendor\Site\MyCatchUpHookFactory


# or use "presets" to set is for all content repositories using this preset
# Neos:
#   ContentRepositoryRegistry:
#     presets:
#       'default':

The actual CatchUp-Hook implements the CatchUpHookInterface and implements its methods. Node that we cannot access the full PHP ContentRepository instance during the CatchUp. Such recursion is not allowed as we cannot mutate the content repository state but only read from the projection.

php
<?php

declare(strict_types=1);

namespace Vendor\Site;

use Neos\ContentRepository\Core\EventStore\EventInterface;
use Neos\ContentRepository\Core\Feature\NodeModification\Event\NodePropertiesWereSet;
use Neos\ContentRepository\Core\NodeType\NodeTypeManager;
use Neos\ContentRepository\Core\Projection\CatchUpHook\CatchUpHookInterface;
use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface;
use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
use Neos\ContentRepository\Core\Subscription\SubscriptionStatus;
use Neos\EventStore\Model\EventEnvelope;

final class MyCatchUpHook implements CatchUpHookInterface
{
    public function __construct(
        private readonly ContentRepositoryId $contentRepositoryId,
        private readonly ContentGraphReadModelInterface $contentGraphReadModel,
        private readonly NodeTypeManager $nodeTypeManager,
        private readonly MyService $myService
    ) {
    }

    /**
     * This hook is called at the beginning of a catch-up run, **after** the database lock is acquired,
     * but **before** any projection was called.
     *
     * Note that any errors thrown will be collected and the current catchup batch will be finished as normal.
     * The collect errors will be returned and rethrown by the content repository.
     */
    public function onBeforeCatchUp(SubscriptionStatus $subscriptionStatus): void
    {
    }

    /**
     * This hook is called for every event during the catchup process, **before** the projection
     * is updated but in the same transaction.
     *
     * Note that any errors thrown will be collected and the current catchup batch will be finished as normal.
     * The collect errors will be returned and rethrown by the content repository.
     */
    public function onBeforeEvent(EventInterface $eventInstance, EventEnvelope $eventEnvelope): void
    {
        if ($eventInstance instanceof NodePropertiesWereSet) {
            $subgraph = $this->contentGraphReadModel->getContentGraph($eventInstance->workspaceName)
                ->getSubgraph($eventInstance->originDimensionSpacePoint->toDimensionSpacePoint(), VisibilityConstraints::createEmpty());
            $node = $subgraph->findNodeById($eventInstance->nodeAggregateId);
            $this->myService->logNodePropertiesBeforeChange($node);
        }
    }

    /**
     * This hook is called for every event during the catchup process, **after** the projection
     * is updated but in the same transaction,
     *
     * Note that any errors thrown will be collected and the current catchup batch will be finished as normal.
     * The collect errors will be returned and rethrown by the content repository.
     */
    public function onAfterEvent(EventInterface $eventInstance, EventEnvelope $eventEnvelope): void
    {
        if ($eventInstance instanceof NodePropertiesWereSet) {
            $subgraph = $this->contentGraphReadModel->getContentGraph($eventInstance->workspaceName)
                ->getSubgraph($eventInstance->originDimensionSpacePoint->toDimensionSpacePoint(), VisibilityConstraints::createEmpty());
            $node = $subgraph->findNodeById($eventInstance->nodeAggregateId);
            $this->myService->logNodePropertiesAfterChange($node);
        }
    }

    /**
     * This hook is called for each batch of processed events during the catchup process, **after** the projection
     * and their new position is updated and the transaction is commited.
     *
     * The database lock is directly acquired again after it is released if the batching needs to continue.
     * It can happen that this method is called even without having seen events in the meantime.
     *
     * Note that any errors thrown will be collected but no further batch is started.
     * The collect errors will be returned and rethrown by the content repository.
     */
    public function onAfterBatchCompleted(): void
    {
    }

    /**
     * This hook is called at the END of a catch-up run, **after** the projection
     * and their new position is updated and the transaction is commited.
     *
     * Note that any errors thrown will be collected and the catchup will finish as normal.
     * The collect errors will be returned and rethrown by the content repository.
     */
    public function onAfterCatchUp(): void
    {
    }
}