diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_3_0/OptimizeInvariantUrlRecords.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_3_0/OptimizeInvariantUrlRecords.cs index 092c0db36e05..631f9042f2ad 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_3_0/OptimizeInvariantUrlRecords.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_3_0/OptimizeInvariantUrlRecords.cs @@ -48,9 +48,6 @@ private void MigrateSqlServer() // Convert existing invariant records to use NULL languageId and remove duplicates. ConvertInvariantDocumentUrlRecords(); ConvertInvariantDocumentUrlAliasRecords(); - - // Trigger rebuild to update the in-memory cache with new structure. - TriggerRebuild(); } private void MigrateSqlite() @@ -73,7 +70,7 @@ private void MigrateSqlite() Create.Table().Do(); Create.Table().Do(); - // Trigger rebuild on startup to repopulate the tables + // Trigger rebuild on startup to repopulate the tables. TriggerRebuild(); } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentUrlAliasRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentUrlAliasRepository.cs index a1ebb6ccc5ca..aedf29066590 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentUrlAliasRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentUrlAliasRepository.cs @@ -73,7 +73,10 @@ public void Save(IEnumerable aliases) // do the deletes and inserts if (toDelete.Count > 0) { - Database.DeleteMany().Where(x => toDelete.Contains(x.Id)).Execute(); + foreach (IEnumerable group in toDelete.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + Database.DeleteMany().Where(x => group.Contains(x.Id)).Execute(); + } } Database.InsertBulk(toInsert.Values); diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentUrlRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentUrlRepository.cs index 0f4af80bf5f0..7547d96893b8 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentUrlRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentUrlRepository.cs @@ -81,7 +81,10 @@ public void Save(IEnumerable publishedDocumentUrlSe // do the deletes, updates and inserts if (toDelete.Count > 0) { - Database.DeleteMany().Where(x => toDelete.Contains(x.NodeId)).Execute(); + foreach (IEnumerable group in toDelete.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + Database.DeleteMany().Where(x => group.Contains(x.NodeId)).Execute(); + } } Database.InsertBulk(toInsert.Values); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs index 253f7aed5221..639ae1f86054 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs @@ -9,6 +9,7 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Builders.Extensions; using Umbraco.Cms.Tests.Common.Testing; @@ -725,4 +726,121 @@ public async Task Changing_ContentType_From_Variant_To_Invariant_Updates_Url_Lan } #endregion + + #region Parameter Count Batching Tests + + [Test] + [Explicit("Slow test that requires LocalDb to reproduce the SQL Server 2100 parameter limit. Run manually to verify the batching fix.")] + public async Task Save_With_Many_Stale_Rows_Does_Not_Exceed_Sql_Parameter_Limit() + { + // Arrange + // This test simulates the upgrade scenario where invariant documents previously had + // URL rows stored per-language (non-null languageId). After the v17.3 optimization, + // invariant documents store with NULL languageId. On rebuild, all old per-language rows + // become deletes. If the delete is not batched with InGroupsOf, sites with many documents + // and languages exceed SQL Server's 2100 parameter limit (SqlException error 8003). + // + // NOTE: SQLite does not enforce a parameter limit, so this test verifies functional + // correctness on SQLite and will catch the SQL Server regression when run with LocalDb. + + // Create languages via the service (simulating a typical multi-language site). + string[] cultureCodes = ["da-DK", "de-DE", "fr-FR", "es-ES", "it-IT", "nl-NL", "pt-PT", "sv-SE", "nb-NO", "fi-FI"]; + var languageIds = new List(); + foreach (var cultureCode in cultureCodes) + { + var language = new LanguageBuilder().WithCultureInfo(cultureCode).Build(); + var result = await LanguageService.CreateAsync(language, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success, $"Failed to create language {cultureCode}"); + languageIds.Add(result.Result!.Id); + } + + // Each document produces (languageCount × 2) stale rows (draft + published per language). + // Compute the required document count dynamically to exceed SQL Server's hard limit of 2100 + // parameters (not just Constants.Sql.MaxParameterCount which is 2000). + const int draftPublishedMultiplier = 2; + const int sqlServerParameterLimit = 2100; + var staleRowsPerDocument = languageIds.Count * draftPublishedMultiplier; + var requiredDocumentCount = (sqlServerParameterLimit / staleRowsPerDocument) + 1; + + // Start with the documents already created by the base class. + var documentKeys = new List { Textpage.Key, Subpage.Key, Subpage2.Key, Subpage3.Key }; + + // Create additional content nodes to reach the required count. + for (var i = documentKeys.Count; i < requiredDocumentCount; i++) + { + var content = ContentBuilder.CreateSimpleContent(ContentType, $"Bulk Page {i}", Textpage.Id); + ContentService.Save(content, -1); + documentKeys.Add(content.Key); + } + + using ICoreScope scope = CoreScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.DocumentUrls); + + var database = ScopeAccessor.AmbientScope!.Database; + + // Delete any existing URL rows to start clean. + database.Execute("DELETE FROM umbracoDocumentUrl"); + + // Insert stale rows: one per (document × language × draft/published). + // These simulate pre-v17.3 data where invariant documents had per-language rows. + var staleRowCount = 0; + foreach (var documentKey in documentKeys) + { + foreach (var languageId in languageIds) + { + foreach (var isDraft in new[] { true, false }) + { + database.Execute( + "INSERT INTO umbracoDocumentUrl (uniqueId, languageId, isDraft, urlSegment, isPrimary) VALUES (@0, @1, @2, @3, @4)", + documentKey, + languageId, + isDraft, + "test-segment", + true); + staleRowCount++; + } + } + } + + Assert.That( + staleRowCount, + Is.GreaterThan(sqlServerParameterLimit), + $"Test setup should create more than {sqlServerParameterLimit} stale rows to exceed SQL Server's parameter limit"); + + // Act - Save new-format data with NULL languageId (invariant). + // Every stale row should be deleted because the keys won't match (null vs non-null languageId). + var newSegments = documentKeys.SelectMany(key => new[] + { + new PublishedDocumentUrlSegment + { + DocumentKey = key, + NullableLanguageId = null, + IsDraft = true, + UrlSegment = "test-segment", + IsPrimary = true, + }, + new PublishedDocumentUrlSegment + { + DocumentKey = key, + NullableLanguageId = null, + IsDraft = false, + UrlSegment = "test-segment", + IsPrimary = true, + }, + }).ToList(); + + // This should not throw SqlException "too many parameters". + Assert.DoesNotThrow(() => DocumentUrlRepository.Save(newSegments)); + + // Verify: old rows deleted, new rows inserted. + var remainingRows = database.ExecuteScalar("SELECT COUNT(*) FROM umbracoDocumentUrl"); + Assert.That( + remainingRows, + Is.EqualTo(newSegments.Count), + "Should have exactly the new invariant rows after save"); + + scope.Complete(); + } + + #endregion }