Improving Editing Performance when using Sitecore Publish Service

The Sitecore Publish Service vastly improves the publish performance in Sitecore. For me it was really hard to get it working properly and I’ve blogged about some of the issues before. I received a lot of good help from Sitecore Support and now it seems like I’ve got into a quite stable state.

However, there is a backside of the Publish Service that may affect the editing performance. Publish Service doesn’t use the PublishQueue table for knowing what to publish. Instead it has an event mechanism for detecting what needs to be published. As an item is saved, Sitecore emits events to the Publish Service so that it knows what pages should be put into the publish manifest.

Note: The solution in this post may not suit every project. Address this only if you’re experiencing the performance decade described and make sure you test everything well. Make sure you fully understand this approach before dropping it into your project.

As part of the Publish Service package, a item:saved event handler is added to do some post processing. When a unversioned field is changed on an item, the event handler loops over all versions of that language and updates the __Revision field. When a shared field is changed on an item, the event handler loops over all versions on all languages and updates the __Revision field. Thereby the Publish Service gets a notification that the content of the item has been changed.

Here’s where the problem started for me. As a shared field is edited, all languages and versions of that item is saved, as just described. This caused performance issues in a large solution I work with. With over 40 languages under version control, an item can easily have 100 versions or more in total. Saving 100 item versions, trigger 100 item:saving events and 100 item:saved events and so on takes time. Many seconds it turned out.

So I tried to optimize this, by reducing the number of fired events so that the Publish Service would be notified without all the heavy load from the rest of the save events. Essentially my approach is to update the __Revision field and save it silently (i.e. without firing the save events) and manually emit the item changed notification to the Publish Service. This reduces the amount of work needed to be performed since:

  • The Save action of the item versioned being edited, is already triggering a refresh on the index on all language versions. No need to do it again
  • The Links database doesn’t need to be updated for every other language version, as only the __Revision field is changed on those.
  • The Globalization ItemEventHandler isn’t needed, as no content is changed.
  • PlaceholderCacheEventHandler shouldn’t be affected by a change in the __Revision field.
  • Update of item archiving and reminders aren’t affected by the revision field.

Note: This solution may only apply to Publish Service 3.0.1. I’ve heard Sitecore is working on another approach to address this. That solution may be under NDA, so I’ll just leave it at that with no further comments.

As mentioned, we do need to emit the notifications to the Publish Service and for that we need to grab an IItemOperationEmitter. To get hold of that one, we need to change how this is registered in the ServicesConfigurator. So we register a replacement of the service configurator, like this:

<services>
  <configurator type="SitecorePatches.Service.CustomPublishingServiceConfigurator, SitecorePatches"
                 patch:instead="*[@type='Sitecore.Publishing.Service.PublishingServiceConfigurator, Sitecore.Publishing.Service']"/>
</services>

The new service configurator is slightly different from the original:

public class CustomPublishingServiceConfigurator : IServicesConfigurator
{
    public virtual void Configure(IServiceCollection serviceCollection)
    {
        var existingPublishServiceDescriptor = serviceCollection.FirstOrDefault(x => x.ImplementationType == typeof(BasePublishManager));
        if (existingPublishServiceDescriptor != null)
            serviceCollection.Remove(existingPublishServiceDescriptor);

        // Add the IItemOperationEmitter into the service locator, so that we can use it later
        var operationsEmitter = ServiceDescriptor.Singleton<IItemOperationEmitter>(serviceProvider =>
        {
            var requiredService = serviceProvider.GetRequiredService<BaseFactory>();
            return (IItemOperationEmitter) requiredService.CreateObject("publishing.service/operationEmitter", true);
        });
        serviceCollection.Add(operationsEmitter);

        var publishServiceDescriptor = ServiceDescriptor.Singleton(sp => {
            var requiredService = sp.GetRequiredService<BaseFactory>();
            // Use the same instance as registered above.
            var emitter = sp.GetRequiredService<IItemOperationEmitter>();
            return (BasePublishManager) ActivatorUtilities.CreateInstance<Sitecore.Publishing.Service.PublishManager>(sp, 
                requiredService.CreateObject(string.Format(CultureInfo.InvariantCulture, "{0}/publishingJobProviderFactory", "publishing.service"), true), 
                requiredService.CreateObject(string.Format(CultureInfo.InvariantCulture, "{0}/publishJobQueueService", "publishing.service"), true), 
                requiredService.CreateObject(string.Format(CultureInfo.InvariantCulture, "{0}/publisherOpsService", "publishing.service"), true),
                emitter);
        });
        serviceCollection.Add(publishServiceDescriptor);
    }
}

We then modify the UpdateItemVariantRevisions event like this:

public void UpdateItemVariantRevisions(object sender, EventArgs args) 
{
    // .... (cut out unchanged code)

    using (new SecurityDisabler())
    {
        // Grab the emitter instance we registered in the DI container above
        var emitter = ServiceLocator.ServiceProvider.GetService<IItemOperationEmitter>();
        var itemPath = savedItem.Paths.GetPath(ItemPathType.ItemID);
        foreach (Item item in itemsToUpdate)
        {
            item.Editing.BeginEdit();
            var revisionGuid = Guid.NewGuid();
            item.Fields[FieldIDs.Revision].SetValue(revisionGuid.ToString(), true);
            item.Editing.EndEdit(false, true);  // Save silently

            // Manually send the notifications to Publish Service:
            var restrictions = new ItemOperationRestrictions(savedItem.Publishing.PublishDate, savedItem.Publishing.UnpublishDate, item.Publishing.ValidFrom, item.Publishing.ValidTo);
            var variantUri = new ItemVariantLocator(item.Database.Name, item.ID.Guid, item.Language.Name, item.Version.Number);
            var operationData = new ItemOperationData(DateTime.UtcNow, restrictions, variantUri, revisionGuid,
                itemPath, Sitecore.Framework.Publishing.PublisherOperations.PublisherOperationType.VariantSaved);
            emitter?.PostOperation(operationData);
        }
    }
}

By using this approach, I got the time for saving an item from several seconds down to well under one second.