diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_3_0/PopulateSortableValueForDatePropertyData.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_3_0/PopulateSortableValueForDatePropertyData.cs index 591bc346c94d..7e5baea7249d 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_3_0/PopulateSortableValueForDatePropertyData.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_3_0/PopulateSortableValueForDatePropertyData.cs @@ -14,6 +14,10 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_3_0; /// public class PopulateSortableValueForDatePropertyData : AsyncMigrationBase { + // Updates are performed in batches to keep individual command durations bounded and to avoid + // holding locks across the entire table for the full duration of the migration. + private const int BatchSize = 10000; + // Property editor aliases that store date/time as JSON and implement IDataValueSortable. private static readonly string[] _dateTimePropertyEditorAliases = [ @@ -37,6 +41,7 @@ public PopulateSortableValueForDatePropertyData( /// protected override Task MigrateAsync() { + EnsureLongCommandTimeout(Database); ExecuteMigration(Database, DatabaseType, _logger); return Task.CompletedTask; } @@ -50,12 +55,43 @@ protected override Task MigrateAsync() /// The number of rows affected. public static int ExecuteMigration(IUmbracoDatabase database, DatabaseType databaseType, ILogger logger) { - // Build the IN clause for the property editor aliases. - var aliasesInClause = string.Join(", ", _dateTimePropertyEditorAliases.Select(a => $"'{a}'")); + // Resolve the relevant property type ids up front so the UPDATE can filter with a static IN list. + // A subquery here causes SQL Server to pick a scan-with-row-goal plan for the batched UPDATE, + // evaluating TRY_CAST(JSON_VALUE(textValue, '$.date')) on every row of umbracoPropertyData before + // the semi-join prunes anything — poor performance on large tables even when no rows ultimately match. + // A static IN list lets the optimizer drive the query off the IX_umbracoPropertyData_PropertyTypeId + // index, so TRY_CAST / datetime() only evaluate on rows already narrowed down by property type. + List propertyTypeIds = GetSortablePropertyTypeIds(database); + if (propertyTypeIds.Count == 0) + { + logger.LogInformation( + "Skipping sortableValue population; no property types using date/time editors were found."); + return 0; + } + + logger.LogInformation( + "Populating sortableValue for property data across {PropertyTypeCount} date/time property type(s).", + propertyTypeIds.Count); + + var idsInClause = string.Join(", ", propertyTypeIds); return databaseType == DatabaseType.SQLite - ? MigrateSQLite(database, aliasesInClause, logger) - : MigrateSqlServer(database, aliasesInClause, logger); + ? MigrateSQLite(database, idsInClause, logger) + : MigrateSqlServer(database, idsInClause, logger); + } + + private static List GetSortablePropertyTypeIds(IUmbracoDatabase database) + { + var aliasesInClause = string.Join(", ", _dateTimePropertyEditorAliases.Select(a => $"'{a}'")); + var sql = $@" +SELECT id +FROM cmsPropertyType +WHERE dataTypeId IN ( + SELECT nodeId + FROM umbracoDataType + WHERE propertyEditorAlias IN ({aliasesInClause}) +)"; + return database.Fetch(sql); } /// @@ -64,36 +100,26 @@ public static int ExecuteMigration(IUmbracoDatabase database, DatabaseType datab /// /// The JSON format is: {"date":"2025-11-05T15:31:00+00:00","timeZone":"UTC"} /// We extract the date, parse it as datetimeoffset, convert to UTC, and format as ISO 8601. - /// The resulting format is: 2025-11-05T15:31:00.0000000+00:00 + /// The resulting format is: 2025-11-05T15:31:00.0000000+00:00. + /// TRY_CAST is used so rows with unparseable dates are skipped rather than aborting the migration; + /// the same guard in the WHERE clause ensures the batch loop always makes progress. + /// The parsed datetimeoffset is computed once via CROSS APPLY and reused for both the filter + /// and the assignment, avoiding a second JSON_VALUE + TRY_CAST per row. /// - private static int MigrateSqlServer(IUmbracoDatabase database, string aliasesInClause, ILogger logger) + private static int MigrateSqlServer(IUmbracoDatabase database, string propertyTypeIdsInClause, ILogger logger) { - // SQL Server: Use ISJSON to validate JSON before parsing, then use JSON_VALUE to extract the date, - // CAST to datetimeoffset, SWITCHOFFSET to convert to UTC, and CONVERT with style 127 for ISO 8601 format. - // Style 127 produces: yyyy-MM-ddTHH:mm:ss.nnnnnnn or yyyy-MM-ddTHH:mm:ss.nnnnnnn+00:00 var sql = $@" -UPDATE umbracoPropertyData -SET sortableValue = CONVERT(varchar(50), SWITCHOFFSET(CAST(JSON_VALUE(textValue, '$.date') AS datetimeoffset), '+00:00'), 127) -WHERE propertyTypeId IN ( - SELECT id - FROM cmsPropertyType - WHERE dataTypeId IN ( - SELECT nodeId - FROM umbracoDataType - WHERE propertyEditorAlias IN ({aliasesInClause}) - ) -) -AND textValue IS NOT NULL -AND sortableValue IS NULL -AND ISJSON(textValue) = 1 -AND JSON_VALUE(textValue, '$.date') IS NOT NULL"; - - var rowsAffected = database.Execute(sql); - logger.LogInformation( - "Populated sortableValue for {RowCount} property data rows using SQL Server JSON functions.", - rowsAffected); +UPDATE TOP ({BatchSize}) pd +SET sortableValue = CONVERT(varchar(50), SWITCHOFFSET(parsed.parsedDate, '+00:00'), 127) +FROM umbracoPropertyData pd +CROSS APPLY (SELECT TRY_CAST(JSON_VALUE(pd.textValue, '$.date') AS datetimeoffset) AS parsedDate) parsed +WHERE pd.propertyTypeId IN ({propertyTypeIdsInClause}) +AND pd.textValue IS NOT NULL +AND pd.sortableValue IS NULL +AND ISJSON(pd.textValue) = 1 +AND parsed.parsedDate IS NOT NULL"; - return rowsAffected; + return ExecuteInBatches(database, sql, logger, "SQL Server"); } /// @@ -103,36 +129,54 @@ AND ISJSON(textValue) = 1 /// SQLite has limited datetime manipulation capabilities, so we extract the date string /// and use strftime to normalize it. For dates with timezone offsets, SQLite's datetime /// function can parse ISO 8601 format and converts to UTC. - /// The resulting format is: yyyy-MM-ddTHH:mm:ssZ + /// The resulting format is: yyyy-MM-ddTHH:mm:ssZ. + /// The datetime() IS NOT NULL guard in the WHERE clause excludes unparseable dates so the + /// batch loop always makes progress (writing NULL back would otherwise re-select the row). /// - private static int MigrateSQLite(IUmbracoDatabase database, string aliasesInClause, ILogger logger) + private static int MigrateSQLite(IUmbracoDatabase database, string propertyTypeIdsInClause, ILogger logger) { - // SQLite: Use json_valid to validate JSON before parsing, then use json_extract to get the date value, - // datetime() to parse and normalize to UTC. The datetime() function automatically handles timezone - // offsets in ISO 8601 format and converts to UTC. - // We then format using strftime to get a consistent sortable format. + // SQLite's default build does not support UPDATE ... LIMIT, so constrain the update via a + // subquery that selects a batch of matching row ids. var sql = $@" UPDATE umbracoPropertyData SET sortableValue = strftime('%Y-%m-%dT%H:%M:%SZ', datetime(json_extract(textValue, '$.date'))) -WHERE propertyTypeId IN ( +WHERE id IN ( SELECT id - FROM cmsPropertyType - WHERE dataTypeId IN ( - SELECT nodeId - FROM umbracoDataType - WHERE propertyEditorAlias IN ({aliasesInClause}) - ) -) -AND textValue IS NOT NULL -AND sortableValue IS NULL -AND json_valid(textValue) = 1 -AND json_extract(textValue, '$.date') IS NOT NULL"; - - var rowsAffected = database.Execute(sql); + FROM umbracoPropertyData + WHERE propertyTypeId IN ({propertyTypeIdsInClause}) + AND textValue IS NOT NULL + AND sortableValue IS NULL + AND json_valid(textValue) = 1 + AND datetime(json_extract(textValue, '$.date')) IS NOT NULL + LIMIT {BatchSize} +)"; + + return ExecuteInBatches(database, sql, logger, "SQLite"); + } + + private static int ExecuteInBatches(IUmbracoDatabase database, string sql, ILogger logger, string databaseProviderName) + { + var totalRowsAffected = 0; + while (true) + { + var rowsAffected = database.Execute(sql); + if (rowsAffected <= 0) + { + break; + } + + totalRowsAffected += rowsAffected; + logger.LogInformation( + "Populated sortableValue for batch of {BatchRowCount} property data rows ({TotalRowCount} total so far).", + rowsAffected, + totalRowsAffected); + } + logger.LogInformation( - "Populated sortableValue for {RowCount} property data rows using SQLite JSON functions.", - rowsAffected); + "Populated sortableValue for {RowCount} property data rows using {DatabaseFlavour} JSON functions.", + totalRowsAffected, + databaseProviderName); - return rowsAffected; + return totalRowsAffected; } }