366 lines
15 KiB
Markdown
366 lines
15 KiB
Markdown
|
|
# 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):**
|
||
|
|
```csharp
|
||
|
|
rollbackSaveResult = Save(content, userId); // Fires ContentSaving/ContentSaved
|
||
|
|
```
|
||
|
|
|
||
|
|
**v1.1 Plan:**
|
||
|
|
```csharp
|
||
|
|
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:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
// 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):**
|
||
|
|
```csharp
|
||
|
|
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:**
|
||
|
|
```csharp
|
||
|
|
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:
|
||
|
|
```csharp
|
||
|
|
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:
|
||
|
|
```csharp
|
||
|
|
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:
|
||
|
|
```csharp
|
||
|
|
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:
|
||
|
|
```csharp
|
||
|
|
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:
|
||
|
|
```csharp
|
||
|
|
[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:
|
||
|
|
```csharp
|
||
|
|
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:
|
||
|
|
```csharp
|
||
|
|
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:
|
||
|
|
```csharp
|
||
|
|
ContentService.Publish(content, userId: Constants.Security.SuperUserId);
|
||
|
|
```
|
||
|
|
|
||
|
|
Or if the overload expects cultures:
|
||
|
|
```csharp
|
||
|
|
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):
|
||
|
|
2. **Resolve DeleteVersion notification semantics** (Issue 2.2): Either preserve the original double-notification behavior for `deletePriorVersions=true`, OR document as intentional breaking change.
|
||
|
|
|
||
|
|
3. **Fix test notification registration** (Issue 3.2): Verify the correct pattern for notification handler testing in integration tests.
|
||
|
|
|
||
|
|
### Consider (Minor):
|
||
|
|
4. **Simplify lock acquisition** in DeleteVersion (Issue 3.1)
|
||
|
|
5. **Add CrudService dependency** if using it in Rollback (Issue 3.3)
|
||
|
|
6. **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`
|