446 lines
15 KiB
Markdown
446 lines
15 KiB
Markdown
|
|
# Critical Implementation Review #5: Phase 1 ContentService CRUD Extraction
|
||
|
|
|
||
|
|
**Plan**: `docs/plans/2025-12-20-contentservice-refactor-phase1-implementation.md` (v1.5)
|
||
|
|
**Reviewer**: Claude (Critical Implementation Review)
|
||
|
|
**Date**: 2025-12-20
|
||
|
|
**Review Type**: Strict Code Review (Pre-Implementation)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 1. Overall Assessment
|
||
|
|
|
||
|
|
### Strengths
|
||
|
|
|
||
|
|
- **Thorough iteration**: Plan v1.5 incorporates feedback from 4 prior critical reviews with detailed change tracking
|
||
|
|
- **Well-documented versioning strategy**: Clear interface extensibility model and deprecation policy
|
||
|
|
- **Proper nested scope elimination**: Extracted `SaveLocked`, `GetContentTypeLocked`, `GetPagedDescendantsLocked` to avoid nested scope creation
|
||
|
|
- **Thread-safe patterns**: Lazy initialization with `LazyThreadSafetyMode.ExecutionAndPublication` for baseline loading and service resolution
|
||
|
|
- **Comprehensive implementation checklist**: All 30+ items from prior reviews tracked with checkboxes
|
||
|
|
- **TDD approach**: Unit tests defined before implementation code
|
||
|
|
|
||
|
|
### Major Concerns
|
||
|
|
|
||
|
|
1. **Critical lock ordering gap** when saving variant (multi-language) content
|
||
|
|
2. **Inconsistent cancellation behavior** between single and batch Save operations
|
||
|
|
3. **Underspecified constructor modifications** for obsolete ContentService constructors
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 2. Critical Issues (P0 - Must Fix Before Implementation)
|
||
|
|
|
||
|
|
### 2.1 Missing Languages Lock When Saving Variant Content
|
||
|
|
|
||
|
|
**Location**: Lines 1110-1124 (`Save(IContent)`) and lines 1142-1204 (`SaveLocked`)
|
||
|
|
|
||
|
|
**Description**:
|
||
|
|
|
||
|
|
The `SaveLocked` method calls `GetLanguageDetailsForAuditEntryLocked(culturesChanging)` at lines 1195-1196. This locked variant has a documented precondition:
|
||
|
|
|
||
|
|
> "Caller MUST hold an active scope with read/write lock on `Constants.Locks.Languages`."
|
||
|
|
|
||
|
|
However, neither `Save()` nor `CreateAndSaveInternal()` acquire this lock:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
// Save() - line 1117 - only acquires ContentTree:
|
||
|
|
scope.WriteLock(Constants.Locks.ContentTree);
|
||
|
|
// Languages lock is NOT acquired
|
||
|
|
|
||
|
|
var result = SaveLocked(scope, content, ...); // Calls GetLanguageDetailsForAuditEntryLocked
|
||
|
|
```
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
// CreateAndSaveInternal() - lines 861-862:
|
||
|
|
scope.WriteLock(Constants.Locks.ContentTree);
|
||
|
|
scope.ReadLock(Constants.Locks.ContentTypes);
|
||
|
|
// Languages lock is NOT acquired
|
||
|
|
|
||
|
|
SaveLocked(scope, content, ...); // Calls GetLanguageDetailsForAuditEntryLocked
|
||
|
|
```
|
||
|
|
|
||
|
|
**Impact**:
|
||
|
|
|
||
|
|
| Risk | Description |
|
||
|
|
|------|-------------|
|
||
|
|
| Data Consistency | `_languageRepository.GetMany()` called without lock protection in multi-threaded scenarios |
|
||
|
|
| Deadlock Potential | Another operation holding Languages lock and waiting for ContentTree could deadlock |
|
||
|
|
| Lock Hierarchy Violation | Breaks the documented lock ordering strategy |
|
||
|
|
|
||
|
|
**Affected Scenarios**: All saves of variant (multi-language) content where `culturesChanging != null`
|
||
|
|
|
||
|
|
**Actionable Fix**:
|
||
|
|
|
||
|
|
Option A (Recommended): Acquire Languages lock in all Save paths:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
// In Save():
|
||
|
|
scope.WriteLock(Constants.Locks.ContentTree);
|
||
|
|
scope.ReadLock(Constants.Locks.Languages); // ADD THIS
|
||
|
|
|
||
|
|
// In CreateAndSaveInternal():
|
||
|
|
scope.WriteLock(Constants.Locks.ContentTree);
|
||
|
|
scope.ReadLock(Constants.Locks.ContentTypes);
|
||
|
|
scope.ReadLock(Constants.Locks.Languages); // ADD THIS
|
||
|
|
```
|
||
|
|
|
||
|
|
Option B (Alternative): Conditionally acquire lock only when needed:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
// In SaveLocked, before accessing languages:
|
||
|
|
if (culturesChanging != null)
|
||
|
|
{
|
||
|
|
scope.ReadLock(Constants.Locks.Languages);
|
||
|
|
var langs = GetLanguageDetailsForAuditEntryLocked(culturesChanging);
|
||
|
|
// ...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Recommendation**: Option A is safer and simpler. The overhead of acquiring an unused read lock is minimal compared to the complexity of conditional locking.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 2.2 Batch Save Inconsistent scope.Complete() on Cancellation
|
||
|
|
|
||
|
|
**Location**: Lines 1231-1234 in batch `Save(IEnumerable<IContent>)`
|
||
|
|
|
||
|
|
**Description**:
|
||
|
|
|
||
|
|
When `ContentSavingNotification` is cancelled, batch Save calls `scope.Complete()`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
if (scope.Notifications.PublishCancelable(savingNotification))
|
||
|
|
{
|
||
|
|
_logger.LogInformation("Batch save operation cancelled...");
|
||
|
|
scope.Complete(); // <-- INCONSISTENT
|
||
|
|
return OperationResult.Cancel(eventMessages);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Comparison with other operations:
|
||
|
|
|
||
|
|
| Operation | On Cancel | Code Location |
|
||
|
|
|-----------|-----------|---------------|
|
||
|
|
| `Save(IContent)` single | No `scope.Complete()` | Lines 1160-1165 |
|
||
|
|
| `Save(IEnumerable<IContent>)` batch | **Calls `scope.Complete()`** | Lines 1231-1234 |
|
||
|
|
| `Delete(IContent)` | No `scope.Complete()` | Lines 1277-1280 |
|
||
|
|
|
||
|
|
**Impact**:
|
||
|
|
|
||
|
|
- Inconsistent behavior between single and batch operations
|
||
|
|
- Semantically questionable: what transaction is being committed on cancellation?
|
||
|
|
- Could commit partial work if any side effects occurred before notification
|
||
|
|
|
||
|
|
**Actionable Fix**:
|
||
|
|
|
||
|
|
Remove `scope.Complete()` from the cancellation path to match single-item Save behavior:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
if (scope.Notifications.PublishCancelable(savingNotification))
|
||
|
|
{
|
||
|
|
_logger.LogInformation("Batch save operation cancelled for {ContentCount} content items by notification handler",
|
||
|
|
contentsA.Length);
|
||
|
|
return OperationResult.Cancel(eventMessages); // No scope.Complete()
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 3. High Priority Issues (P1 - Should Fix)
|
||
|
|
|
||
|
|
### 3.1 Task 5 Obsolete Constructor Modification Underspecified
|
||
|
|
|
||
|
|
**Location**: Task 5, Steps 1-3
|
||
|
|
|
||
|
|
**Description**:
|
||
|
|
|
||
|
|
The plan describes using `Lazy<IContentCrudService>` for obsolete constructors but doesn't show complete constructor modifications. The existing ContentService has two obsolete constructors (lines 91-169) that chain to the primary constructor via `: this(...)`.
|
||
|
|
|
||
|
|
**Current pattern (simplified)**:
|
||
|
|
```csharp
|
||
|
|
[Obsolete("...")]
|
||
|
|
public ContentService(/* old params */)
|
||
|
|
: this(/* chain to primary constructor */)
|
||
|
|
{
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Unclear aspects**:
|
||
|
|
1. How does chaining work when primary constructor now requires `IContentCrudService`?
|
||
|
|
2. Do both obsolete constructors need identical treatment?
|
||
|
|
3. What is the complete body of modified obsolete constructors?
|
||
|
|
|
||
|
|
**Actionable Fix**:
|
||
|
|
|
||
|
|
Provide complete obsolete constructor specification. The obsolete constructors should NOT chain to the new primary constructor. Instead, they should have their own full body:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
[Obsolete("Use the non-obsolete constructor instead. Scheduled removal in v19.")]
|
||
|
|
public ContentService(
|
||
|
|
ICoreScopeProvider provider,
|
||
|
|
ILoggerFactory loggerFactory,
|
||
|
|
IEventMessagesFactory eventMessagesFactory,
|
||
|
|
IDocumentRepository documentRepository,
|
||
|
|
IEntityRepository entityRepository,
|
||
|
|
IAuditRepository auditRepository, // Old parameter
|
||
|
|
IContentTypeRepository contentTypeRepository,
|
||
|
|
IDocumentBlueprintRepository documentBlueprintRepository,
|
||
|
|
ILanguageRepository languageRepository,
|
||
|
|
Lazy<IPropertyValidationService> propertyValidationService,
|
||
|
|
IShortStringHelper shortStringHelper,
|
||
|
|
ICultureImpactFactory cultureImpactFactory,
|
||
|
|
IUserIdKeyResolver userIdKeyResolver,
|
||
|
|
PropertyEditorCollection propertyEditorCollection,
|
||
|
|
IIdKeyMap idKeyMap,
|
||
|
|
IOptionsMonitor<ContentSettings> optionsMonitor,
|
||
|
|
IRelationService relationService)
|
||
|
|
: base(provider, loggerFactory, eventMessagesFactory)
|
||
|
|
{
|
||
|
|
// All existing field assignments...
|
||
|
|
_documentRepository = documentRepository;
|
||
|
|
_entityRepository = entityRepository;
|
||
|
|
// ... etc ...
|
||
|
|
|
||
|
|
// NEW: Lazy resolution of IContentCrudService
|
||
|
|
_crudServiceLazy = new Lazy<IContentCrudService>(() =>
|
||
|
|
StaticServiceProvider.Instance.GetRequiredService<IContentCrudService>(),
|
||
|
|
LazyThreadSafetyMode.ExecutionAndPublication);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 3.2 Unit Tests Missing Variant Culture Save Path Coverage
|
||
|
|
|
||
|
|
**Location**: Task 3, Step 1 (`ContentCrudServiceTests.cs`)
|
||
|
|
|
||
|
|
**Description**:
|
||
|
|
|
||
|
|
The unit tests cover basic scenarios but don't exercise the variant culture path in `SaveLocked` (lines 1175-1197), which:
|
||
|
|
1. Checks `content.ContentType.VariesByCulture()`
|
||
|
|
2. Accesses `content.CultureInfos`
|
||
|
|
3. Calls `GetLanguageDetailsForAuditEntryLocked()`
|
||
|
|
|
||
|
|
This is the exact code path affected by the critical lock bug (2.1).
|
||
|
|
|
||
|
|
**Actionable Fix**:
|
||
|
|
|
||
|
|
Add a unit test for variant content saving:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
[Test]
|
||
|
|
public void Save_WithVariantContent_CallsLanguageRepository()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var scope = CreateMockScopeWithWriteLock();
|
||
|
|
|
||
|
|
var contentType = new Mock<IContentType>();
|
||
|
|
contentType.Setup(x => x.VariesByCulture()).Returns(true);
|
||
|
|
|
||
|
|
var cultureInfo = new Mock<IContentCultureInfo>();
|
||
|
|
cultureInfo.Setup(x => x.IsDirty()).Returns(true);
|
||
|
|
cultureInfo.Setup(x => x.Culture).Returns("en-US");
|
||
|
|
|
||
|
|
var cultureInfos = new Mock<IDictionary<string, IContentCultureInfo>>();
|
||
|
|
cultureInfos.Setup(x => x.Values).Returns(new[] { cultureInfo.Object });
|
||
|
|
|
||
|
|
var content = new Mock<IContent>();
|
||
|
|
content.Setup(x => x.ContentType).Returns(contentType.Object);
|
||
|
|
content.Setup(x => x.CultureInfos).Returns(cultureInfos.Object);
|
||
|
|
content.Setup(x => x.HasIdentity).Returns(true);
|
||
|
|
content.Setup(x => x.PublishedState).Returns(PublishedState.Unpublished);
|
||
|
|
content.Setup(x => x.Name).Returns("Test");
|
||
|
|
|
||
|
|
_languageRepository.Setup(x => x.GetMany()).Returns(new List<ILanguage>());
|
||
|
|
|
||
|
|
// Act
|
||
|
|
var result = _sut.Save(content.Object);
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
Assert.That(result.Success, Is.True);
|
||
|
|
_languageRepository.Verify(x => x.GetMany(), Times.Once);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 4. Medium Priority Issues (P2 - Recommended)
|
||
|
|
|
||
|
|
### 4.1 Missing Using Statements in Code Samples
|
||
|
|
|
||
|
|
**Location**: Task 3, ContentCrudService.cs code block
|
||
|
|
|
||
|
|
**Description**:
|
||
|
|
|
||
|
|
The code uses types requiring imports not shown in the using block:
|
||
|
|
- `TextColumnType` requires `Umbraco.Cms.Core.Persistence.Querying`
|
||
|
|
- `Direction` requires `Umbraco.Cms.Core.Persistence.Querying`
|
||
|
|
- `Ordering` requires `Umbraco.Cms.Core.Persistence.Querying`
|
||
|
|
|
||
|
|
**Actionable Fix**:
|
||
|
|
|
||
|
|
Add to the using statements at the top of ContentCrudService.cs:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Umbraco.Cms.Core.Persistence.Querying;
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 4.2 RecordBenchmark vs AssertNoRegression Clarification
|
||
|
|
|
||
|
|
**Location**: Task 6, Step 2
|
||
|
|
|
||
|
|
**Description**:
|
||
|
|
|
||
|
|
The plan adds `AssertNoRegression` which internally calls `RecordBenchmark`. The example shows calling `AssertNoRegression` only, which is correct. However, the instruction text could be clearer that benchmarks should REPLACE `RecordBenchmark` calls with `AssertNoRegression`, not add both.
|
||
|
|
|
||
|
|
**Actionable Fix**:
|
||
|
|
|
||
|
|
Update Step 2 text to:
|
||
|
|
|
||
|
|
> "**Replace** `RecordBenchmark(...)` calls with `AssertNoRegression(...)` calls in the following 10 Phase 1 CRUD benchmarks. The `AssertNoRegression` method internally records the benchmark AND asserts no regression."
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 4.3 GetAncestors Warning Log Could Be Noisy
|
||
|
|
|
||
|
|
**Location**: Lines 1024-1029
|
||
|
|
|
||
|
|
**Description**:
|
||
|
|
|
||
|
|
`GetAncestors` logs a warning when `ancestorIds.Length == 0 && content.Level > 1`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
_logger.LogWarning(
|
||
|
|
"Malformed path '{Path}' for content {ContentId} at level {Level} - expected ancestors but found none",
|
||
|
|
content.Path, content.Id, content.Level);
|
||
|
|
```
|
||
|
|
|
||
|
|
This warning could be noisy in edge cases or during data migration.
|
||
|
|
|
||
|
|
**Actionable Fix**:
|
||
|
|
|
||
|
|
Consider changing to `LogDebug` or adding expected count for clarity:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
_logger.LogWarning(
|
||
|
|
"Malformed path '{Path}' for content {ContentId} at level {Level} - expected {ExpectedCount} ancestors but parsed {ActualCount}",
|
||
|
|
content.Path, content.Id, content.Level, content.Level - 1, ancestorIds.Length);
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 4.4 Benchmark CI Failure Mode Consideration
|
||
|
|
|
||
|
|
**Location**: Task 6, `AssertNoRegression` implementation
|
||
|
|
|
||
|
|
**Description**:
|
||
|
|
|
||
|
|
If the baseline file doesn't exist, `AssertNoRegression` logs and skips the regression check. In CI environments, this could mask regressions if the baseline file is missing.
|
||
|
|
|
||
|
|
**Actionable Fix (Optional)**:
|
||
|
|
|
||
|
|
Add optional strict mode via environment variable:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
private static readonly bool RequireBaseline =
|
||
|
|
bool.TryParse(Environment.GetEnvironmentVariable("BENCHMARK_REQUIRE_BASELINE"), out var b) && b;
|
||
|
|
|
||
|
|
protected void AssertNoRegression(...)
|
||
|
|
{
|
||
|
|
RecordBenchmark(name, elapsedMs, itemCount);
|
||
|
|
|
||
|
|
if (Baseline.TryGetValue(name, out var baselineResult))
|
||
|
|
{
|
||
|
|
// ... existing regression check ...
|
||
|
|
}
|
||
|
|
else if (RequireBaseline)
|
||
|
|
{
|
||
|
|
Assert.Fail($"No baseline entry found for '{name}' and BENCHMARK_REQUIRE_BASELINE=true");
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
TestContext.WriteLine($"[REGRESSION_CHECK] {name}: SKIPPED (no baseline entry)");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 5. Questions for Clarification
|
||
|
|
|
||
|
|
### Q1: Lock Ordering Strategy for Languages Lock
|
||
|
|
|
||
|
|
When fixing issue 2.1, should the Languages lock be acquired:
|
||
|
|
- **Always** in `Save()` and `CreateAndSaveInternal()` (simpler, minor overhead for invariant content), or
|
||
|
|
- **Conditionally** only when `content.ContentType.VariesByCulture()` returns true (more complex, avoids unnecessary locks)?
|
||
|
|
|
||
|
|
**Recommendation**: Always acquire. The read lock overhead is minimal.
|
||
|
|
|
||
|
|
### Q2: Obsolete Constructor Strategy
|
||
|
|
|
||
|
|
For the obsolete constructors:
|
||
|
|
- Should they stop chaining to the primary constructor and have their own full body?
|
||
|
|
- Or should an intermediate constructor be added that accepts optional `IContentCrudService`?
|
||
|
|
|
||
|
|
**Recommendation**: Full body approach (as shown in fix 3.1) is cleaner and avoids parameter default complexity.
|
||
|
|
|
||
|
|
### Q3: Baseline Required in CI
|
||
|
|
|
||
|
|
Should the CI pipeline require baseline file presence? If so, add the `BENCHMARK_REQUIRE_BASELINE` environment variable check suggested in 4.4.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 6. Implementation Checklist Additions
|
||
|
|
|
||
|
|
Add to the existing checklist:
|
||
|
|
|
||
|
|
### From Critical Review 5
|
||
|
|
|
||
|
|
- [ ] `Save()` acquires `Constants.Locks.Languages` read lock
|
||
|
|
- [ ] `CreateAndSaveInternal()` acquires `Constants.Locks.Languages` read lock
|
||
|
|
- [ ] Batch `Save()` does NOT call `scope.Complete()` on cancellation
|
||
|
|
- [ ] Obsolete constructors have complete body specification (not chained)
|
||
|
|
- [ ] Unit test added for variant culture save path
|
||
|
|
- [ ] `using Umbraco.Cms.Core.Persistence.Querying;` added to ContentCrudService.cs
|
||
|
|
- [ ] Task 6 Step 2 clarifies "replace" not "add" AssertNoRegression
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 7. Final Recommendation
|
||
|
|
|
||
|
|
| Verdict | **Major Revisions Needed** |
|
||
|
|
|---------|----------------------------|
|
||
|
|
|
||
|
|
The plan is comprehensive and well-structured after 4 prior reviews, but the **missing Languages lock** (Issue 2.1) is a correctness bug that will affect all multi-language content saves. This must be fixed before implementation.
|
||
|
|
|
||
|
|
### Required Changes (Blocking)
|
||
|
|
|
||
|
|
| Priority | Issue | Action |
|
||
|
|
|----------|-------|--------|
|
||
|
|
| P0 | 2.1 Missing Languages Lock | Acquire `scope.ReadLock(Constants.Locks.Languages)` in `Save()` and `CreateAndSaveInternal()` |
|
||
|
|
| P0 | 2.2 Inconsistent scope.Complete() | Remove `scope.Complete()` from batch Save cancellation path |
|
||
|
|
| P1 | 3.1 Constructor Underspecified | Add complete obsolete constructor body to Task 5 |
|
||
|
|
|
||
|
|
### Recommended Changes (Non-Blocking)
|
||
|
|
|
||
|
|
| Priority | Issue | Action |
|
||
|
|
|----------|-------|--------|
|
||
|
|
| P1 | 3.2 Missing Test Coverage | Add unit test for variant culture save |
|
||
|
|
| P2 | 4.1 Using Statements | Add missing using to code sample |
|
||
|
|
| P2 | 4.2 Task 6 Clarification | Clarify "replace" RecordBenchmark |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 8. Summary
|
||
|
|
|
||
|
|
**Plan Version**: 1.5
|
||
|
|
**Critical Issues Found**: 2
|
||
|
|
**High Priority Issues Found**: 2
|
||
|
|
**Medium Priority Issues Found**: 4
|
||
|
|
|
||
|
|
After addressing the critical lock ordering issue and scope.Complete() inconsistency, the plan will be production-ready. The prior 4 reviews have successfully caught and fixed the majority of implementation concerns—this review identified edge cases in the variant content save path that were not fully covered.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
*Review completed: 2025-12-20*
|