Files
Umbraco-CMS/docs/plans/2025-12-23-contentservice-refactor-phase3-implementation-critical-review-2.md
yv01p 99ce3bb5aa docs: add Phase 3 critical reviews and completion summary
Includes:
- 3 critical implementation reviews (v1, v2, v3)
- Task 3 and Task 5 reviews
- Phase 3 completion summary

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 05:17:02 +00:00

15 KiB

Critical Implementation Review: ContentService Phase 3 - Version Operations Extraction (v1.1)

Review Date: 2025-12-23 Reviewer: Claude (Senior Staff Engineer) Plan Version: 1.1 Prior Review: 2025-12-23-contentservice-refactor-phase3-implementation-critical-review-1.md Status: Approve with Changes


1. Overall Assessment

The v1.1 plan incorporates fixes from the first critical review and demonstrates improved robustness. The consolidated scoping in Rollback, the added ReadLock in GetVersionIds, and the deterministic test patterns all represent meaningful improvements.

Strengths:

  • All five critical/important issues from Review 1 have been addressed
  • Clear version history documentation showing what was changed and why
  • Consolidated scoping eliminates the TOCTOU race condition
  • Deterministic test patterns replace flaky Thread.Sleep calls
  • Good commit message hygiene documenting fixes applied

Remaining Concerns:

  1. Major Behavioral Change in Rollback: The fix bypasses ContentSaving/ContentSaved notifications by using DocumentRepository.Save directly instead of ContentService.Save
  2. Behavioral Change in DeleteVersion with deletePriorVersions: The fix changes notification semantics for prior version deletion
  3. Minor test infrastructure issues: Notification registration pattern may not work as written

2. Critical Issues

2.1 Rollback Bypasses ContentSaving/ContentSaved Notifications

Location: Task 2, Rollback method (lines 369-379)

Description: The v1.1 fix uses DocumentRepository.Save(content) directly to avoid nested scope issues. However, the original ContentService.Rollback calls Save(content, userId) which is the ContentService's own Save method. This fires ContentSavingNotification and ContentSavedNotification.

Original Behavior (ContentService.Rollback lines 275):

rollbackSaveResult = Save(content, userId);  // Fires ContentSaving/ContentSaved

v1.1 Plan:

DocumentRepository.Save(content);  // NO ContentSaving/ContentSaved!

Notification Sequence Comparison:

Original v1.1 Plan
1. ContentRollingBack 1. ContentRollingBack
2. ContentSaving (missing)
3. ContentSaved (missing)
4. ContentRolledBack 2. ContentRolledBack

Why It Matters:

  • Breaking Change: Notification handlers subscribing to ContentSavedNotification during rollback will no longer be triggered
  • Audit Gap: The ContentService Save method includes its own audit trail entry for content saves
  • Validation Bypass: The Save method performs validation via IPropertyValidationService which is now skipped
  • Cache Invalidation Risk: Some cache refreshers may depend on ContentSavedNotification

Specific Fix: Since ContentService.Save creates an ambient scope (it joins the existing scope), calling it within the consolidated Rollback scope should work correctly. Replace the direct repository call:

// Instead of:
try
{
    scope.WriteLock(Constants.Locks.ContentTree);
    DocumentRepository.Save(content);
}
catch (Exception ex)
{
    _logger.LogError(ex, "User '{UserId}' was unable to rollback content '{ContentId}' to version '{VersionId}'", userId, id, versionId);
    scope.Complete();
    return new OperationResult(OperationResultType.Failed, evtMsgs);
}

// Use CrudService which implements the same save logic:
scope.WriteLock(Constants.Locks.ContentTree);
var saveResult = CrudService.Save(content, userId);
if (!saveResult.Success)
{
    _logger.LogError("User '{UserId}' was unable to rollback content '{ContentId}' to version '{VersionId}'", userId, id, versionId);
    scope.Complete();
    return new OperationResult(OperationResultType.Failed, evtMsgs);
}

Alternative: If the original behavior of NOT firing ContentSaving/ContentSaved during rollback is actually desired (it may be intentional), then:

  1. Document this as an intentional behavioral change
  2. Add a unit test verifying the notification sequence
  3. Update the interface documentation

2.2 DeleteVersion with deletePriorVersions Changes Notification Semantics

Location: Task 2, DeleteVersion method (lines 439-446)

Description: The v1.1 fix correctly avoids nested scopes by calling DocumentRepository.DeleteVersions() directly. However, this changes the notification behavior.

Original Behavior (ContentService.DeleteVersion lines 2025-2028):

if (deletePriorVersions)
{
    IContent? content = GetVersion(versionId);
    DeleteVersions(id, content?.UpdateDate ?? DateTime.UtcNow, userId);  // Fires its own notifications!
}

The original calls DeleteVersions() which publishes:

  • ContentDeletingVersionsNotification (with dateToRetain)
  • ContentDeletedVersionsNotification (with dateToRetain)

v1.1 Plan:

if (deletePriorVersions)
{
    scope.ReadLock(Constants.Locks.ContentTree);
    IContent? versionContent = DocumentRepository.GetVersion(versionId);
    DateTime cutoffDate = versionContent?.UpdateDate ?? DateTime.UtcNow;

    scope.WriteLock(Constants.Locks.ContentTree);
    DocumentRepository.DeleteVersions(id, cutoffDate);  // No notifications!
}

Notification Sequence Comparison for DeleteVersion(id, versionId, deletePriorVersions: true):

Original v1.1 Plan
1. ContentDeletingVersions (versionId) 1. ContentDeletingVersions (versionId)
2. ContentDeletingVersions (dateToRetain) (missing)
3. ContentDeletedVersions (dateToRetain) (missing)
4. ContentDeletedVersions (versionId) 2. ContentDeletedVersions (versionId)

Why It Matters:

  • Handlers expecting notifications for bulk prior-version deletion will not be triggered
  • The existing behavior (firing multiple notifications) may be relied upon
  • This was flagged as a "quirk" in Review 1's Question 3, but the fix removes the behavior entirely

Specific Fix: This requires a design decision:

Option A - Preserve Original Behavior: Inline the notification firing:

if (deletePriorVersions)
{
    scope.ReadLock(Constants.Locks.ContentTree);
    IContent? versionContent = DocumentRepository.GetVersion(versionId);
    DateTime cutoffDate = versionContent?.UpdateDate ?? DateTime.UtcNow;

    // Publish notifications for prior versions (matching original behavior)
    var priorVersionsNotification = new ContentDeletingVersionsNotification(id, evtMsgs, dateToRetain: cutoffDate);
    if (!scope.Notifications.PublishCancelable(priorVersionsNotification))
    {
        scope.WriteLock(Constants.Locks.ContentTree);
        DocumentRepository.DeleteVersions(id, cutoffDate);
        scope.Notifications.Publish(
            new ContentDeletedVersionsNotification(id, evtMsgs, dateToRetain: cutoffDate)
                .WithStateFrom(priorVersionsNotification));
    }
}

Option B - Document Breaking Change: If the double-notification was an unintended quirk:

  1. Add to the plan's v1.1 Changes Summary: "Breaking Change: DeleteVersion with deletePriorVersions=true now fires one notification set instead of two"
  2. Add a migration/release note

Recommended: Option A (preserve behavior) unless there's explicit confirmation this quirk should be removed.


3. Minor Issues & Improvements

3.1 Redundant WriteLock Acquisition in DeleteVersion

Location: Task 2, DeleteVersion method (lines 441, 445, 449)

Description: The method acquires WriteLock multiple times:

if (deletePriorVersions)
{
    // ...
    scope.WriteLock(Constants.Locks.ContentTree);  // First acquisition
    DocumentRepository.DeleteVersions(id, cutoffDate);
}

scope.WriteLock(Constants.Locks.ContentTree);  // Second acquisition (redundant if deletePriorVersions was true)
IContent? c = DocumentRepository.Get(id);

Why It Matters:

  • Not a bug (locks are idempotent), but adds unnecessary noise
  • Makes code harder to reason about

Specific Fix: Restructure to acquire the write lock once:

scope.WriteLock(Constants.Locks.ContentTree);

if (deletePriorVersions)
{
    IContent? versionContent = DocumentRepository.GetVersion(versionId);
    DateTime cutoffDate = versionContent?.UpdateDate ?? DateTime.UtcNow;
    DocumentRepository.DeleteVersions(id, cutoffDate);
}

IContent? c = DocumentRepository.Get(id);
// ...

Note: This also avoids the lock upgrade pattern (read → write) which can be problematic in some scenarios.

3.2 Test Notification Registration Pattern May Not Compile

Location: Task 8, Rollback_WhenNotificationCancelled_ReturnsCancelledResult test (lines 1066-1085)

Description: The test uses:

NotificationHandler.Add<ContentRollingBackNotification>(notificationHandler);
// ...
NotificationHandler.Remove<ContentRollingBackNotification>(notificationHandler);

Why It Matters:

  • UmbracoIntegrationTest doesn't expose a NotificationHandler property
  • The pattern doesn't match existing test patterns in the codebase

Specific Fix: Use the builder pattern available in integration tests:

[Test]
public void Rollback_WhenNotificationCancelled_ReturnsCancelledResult()
{
    // Arrange
    var contentType = CreateContentType();
    var content = CreateAndSaveContent(contentType);
    content.SetValue("title", "Original Value");
    ContentService.Save(content);
    var originalVersionId = content.VersionId;

    content.SetValue("title", "Changed Value");
    ContentService.Save(content);

    // Use the existing notification handler testing pattern
    ContentRollingBackNotification? capturedNotification = null;

    // Register via the scope's notification system or use INotificationHandler registration
    var handler = GetRequiredService<IEventAggregator>();
    // Or use WithNotificationHandler<> pattern from test base

    // ... verify cancellation behavior
}

Alternatively, look at existing cancellation tests in the codebase (e.g., ContentService tests) for the correct pattern.

3.3 Constructor Dependency on IContentCrudService Missing

Location: Task 2, ContentVersionOperationService constructor

Description: If the fix for Issue 2.1 is implemented (using CrudService.Save), the ContentVersionOperationService will need to inject IContentCrudService. Currently, the implementation only inherits from ContentServiceBase which doesn't provide access to CrudService.

Specific Fix: Either: (A) Add IContentCrudService as a constructor parameter and inject it, OR (B) Expose CrudService from ContentServiceBase (requires base class modification)

If using Option A:

public class ContentVersionOperationService : ContentServiceBase, IContentVersionOperationService
{
    private readonly ILogger<ContentVersionOperationService> _logger;
    private readonly IContentCrudService _crudService;

    public ContentVersionOperationService(
        ICoreScopeProvider provider,
        ILoggerFactory loggerFactory,
        IEventMessagesFactory eventMessagesFactory,
        IDocumentRepository documentRepository,
        IAuditService auditService,
        IUserIdKeyResolver userIdKeyResolver,
        IContentCrudService crudService)  // NEW
        : base(provider, loggerFactory, eventMessagesFactory, documentRepository, auditService, userIdKeyResolver)
    {
        _logger = loggerFactory.CreateLogger<ContentVersionOperationService>();
        _crudService = crudService;
    }
    // ...
}

3.4 Publish Method Signature in Test

Location: Task 8, DeleteVersion_PublishedVersion_DoesNotDelete test (line 1187)

Description: The test calls:

ContentService.Publish(content, Array.Empty<string>());

Why It Matters:

  • Should verify this signature exists on IContentService
  • The second parameter (cultures array) may need to be null or a specific culture depending on the content configuration

Specific Fix: Verify against IContentService interface. If the content type is not variant, use:

ContentService.Publish(content, userId: Constants.Security.SuperUserId);

Or if the overload expects cultures:

ContentService.Publish(content, new[] { "*" });  // All cultures

4. Questions for Clarification

  1. ContentSaving/ContentSaved During Rollback: Is it intentional that the v1.1 implementation no longer fires these notifications? The original implementation fires them via Save(content, userId). If this is intentional, it should be documented as a behavioral change.

  2. Double Notification in DeleteVersion: Should DeleteVersion(id, versionId, deletePriorVersions: true) fire notifications for both the prior versions AND the specific version (original behavior) or just the specific version (v1.1 behavior)?

  3. Test Infrastructure: What is the correct pattern for registering notification handlers in integration tests? The proposed pattern (NotificationHandler.Add<>) doesn't match the UmbracoIntegrationTest API.


5. Final Recommendation

Approve with Changes

The v1.1 plan has addressed the critical scoping and race condition issues from Review 1. However, two significant behavioral changes need resolution before implementation:

Must Fix (Critical):

  1. Resolve Rollback notification semantics (Issue 2.1): Either restore ContentSaving/ContentSaved notifications by using CrudService.Save, OR explicitly document this as an intentional breaking change with a test validating the new behavior.

Should Fix (Important):

  1. Resolve DeleteVersion notification semantics (Issue 2.2): Either preserve the original double-notification behavior for deletePriorVersions=true, OR document as intentional breaking change.

  2. Fix test notification registration (Issue 3.2): Verify the correct pattern for notification handler testing in integration tests.

Consider (Minor):

  1. Simplify lock acquisition in DeleteVersion (Issue 3.1)
  2. Add CrudService dependency if using it in Rollback (Issue 3.3)
  3. Verify Publish method signature in test (Issue 3.4)

Once Issues 2.1 and 2.2 are resolved with either preservation or explicit documentation, the plan is ready for implementation.


Appendix: Review Comparison

Issue from Review 1 Status in v1.1 New Issue?
2.1 TOCTOU Race Fixed ⚠️ Introduces notification bypass
2.2 Error Handling Fixed -
2.3 Missing ReadLock Fixed -
2.4 Nested Scope Fixed ⚠️ Introduces notification change
2.5 Thread.Sleep Fixed -
3.2 Cancellation Test Added ⚠️ May not compile
3.3 Published Version Test Added ⚠️ Publish signature unclear
3.4 Interface Docs Improved -

Review conducted against:

  • ContentService.cs (lines 243-298, 1970-2050)
  • ContentVersionOperationService.cs (proposed in plan)
  • ContentServiceBase.cs (base class reference)
  • Review 1: 2025-12-23-contentservice-refactor-phase3-implementation-critical-review-1.md