-
Notifications
You must be signed in to change notification settings - Fork 22
Page the "Register content move redirects" job so it survives large tables (6.x backport of #188) #193
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: release/v6
Are you sure you want to change the base?
Page the "Register content move redirects" job so it survives large tables (6.x backport of #188) #193
Changes from all commits
a1e9f0b
966068e
6d9ffa6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,8 @@ | |
| using EPiServer.PlugIn; | ||
| using EPiServer.Scheduler; | ||
| using Geta.NotFoundHandler.Optimizely.Infrastructure; | ||
| using Geta.NotFoundHandler.Optimizely.Infrastructure.Configuration; | ||
| using Microsoft.Extensions.Options; | ||
|
|
||
| namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects | ||
| { | ||
|
|
@@ -17,60 +19,78 @@ public class RegisterMovedContentRedirectsJob : ScheduledJobBase | |
| private readonly IContentUrlHistoryLoader _contentUrlHistoryLoader; | ||
| private readonly JobStatusLogger _jobStatusLogger; | ||
| private readonly IAutomaticRedirectsService _automaticRedirectsService; | ||
| private readonly int _batchSize; | ||
| private bool _stopped; | ||
|
|
||
| public RegisterMovedContentRedirectsJob( | ||
| IAutomaticRedirectsService automaticRedirectsService, | ||
| IContentUrlHistoryLoader contentUrlHistoryLoader) | ||
| IContentUrlHistoryLoader contentUrlHistoryLoader, | ||
| IOptions<OptimizelyNotFoundHandlerOptions> options) | ||
| { | ||
| _automaticRedirectsService = automaticRedirectsService; | ||
| _contentUrlHistoryLoader = contentUrlHistoryLoader; | ||
| // Clamp to >= 1: a misconfigured 0/negative would page with FETCH NEXT 0 ROWS (invalid SQL) and fail the job. | ||
| _batchSize = Math.Max(1, options.Value.MovedContentBatchSize); | ||
| _jobStatusLogger = new JobStatusLogger(OnStatusChanged); | ||
|
|
||
| IsStoppable = true; | ||
| } | ||
|
Comment on lines
25
to
37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The public RegisterMovedContentRedirectsJob(
IAutomaticRedirectsService automaticRedirectsService,
IContentUrlHistoryLoader contentUrlHistoryLoader,
IOptions<OptimizelyNotFoundHandlerOptions> options)
{
_automaticRedirectsService = automaticRedirectsService ?? throw new ArgumentNullException(nameof(automaticRedirectsService));
_contentUrlHistoryLoader = contentUrlHistoryLoader ?? throw new ArgumentNullException(nameof(contentUrlHistoryLoader));
_batchSize = Math.Max(1, options?.Value?.MovedContentBatchSize ?? 1000);
_jobStatusLogger = new JobStatusLogger(OnStatusChanged);
IsStoppable = true;
} |
||
|
|
||
| public override string Execute() | ||
| { | ||
| var movedContent = _contentUrlHistoryLoader.GetAllMoved().ToList(); | ||
| var totalCount = movedContent.Count; | ||
| var successCount = 0; | ||
| var failedCount = 0; | ||
| var currentCount = 0; | ||
| var skip = 0; | ||
|
|
||
| _jobStatusLogger.LogWithStatus($"In total will process moved content: {totalCount}"); | ||
| _jobStatusLogger.LogWithStatus($"Processing moved content in batches of {_batchSize}"); | ||
|
|
||
| foreach (var content in movedContent) | ||
| // Page through the moved content rather than materialising the whole table up front. | ||
| // CreateRedirects only touches the redirects store, not ContentUrlHistory, so the moved | ||
| // set is stable for the duration of the run and skip-based paging never skips a key. | ||
| while (true) | ||
| { | ||
| if (_stopped) | ||
| var batch = _contentUrlHistoryLoader.GetAllMoved(skip, _batchSize).ToList(); | ||
|
|
||
| foreach (var content in batch) | ||
| { | ||
| _jobStatusLogger.Log( | ||
| $"Job was stopped, successful content handled before stopped: {successCount} out of total {totalCount} content"); | ||
| return _jobStatusLogger.ToString(); | ||
| } | ||
| if (_stopped) | ||
| { | ||
| _jobStatusLogger.Log( | ||
| $"Job was stopped, successful content handled before stopped: {successCount} out of {currentCount} processed"); | ||
| return _jobStatusLogger.ToString(); | ||
| } | ||
|
|
||
| currentCount++; | ||
| currentCount++; | ||
|
|
||
| try | ||
| { | ||
| _automaticRedirectsService.CreateRedirects(content.histories); | ||
| successCount++; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _jobStatusLogger.Log($"Processing [{content.contentKey}] failed, exception: {ex}"); | ||
| failedCount++; | ||
| try | ||
| { | ||
| _automaticRedirectsService.CreateRedirects(content.histories); | ||
| successCount++; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _jobStatusLogger.Log($"Processing [{content.contentKey}] failed, exception: {ex}"); | ||
| failedCount++; | ||
| } | ||
|
|
||
| if (currentCount % 500 == 0) | ||
| { | ||
| _jobStatusLogger.Status( | ||
| $"Processed {currentCount}, of whom successful {successCount}; failed: {failedCount}"); | ||
| } | ||
| } | ||
|
|
||
| if (currentCount % 500 == 0) | ||
| if (batch.Count < _batchSize) | ||
| { | ||
| _jobStatusLogger.Status( | ||
| $"Processed {currentCount} of whom successful {successCount} out of total {totalCount} content; failed: {failedCount}"); | ||
| break; | ||
| } | ||
|
|
||
| skip += _batchSize; | ||
| } | ||
|
|
||
| _jobStatusLogger.Log( | ||
| $"Processed {currentCount} of whom successful {successCount} out of total {totalCount} content; failed: {failedCount}"); | ||
| $"Processed {currentCount}, of whom successful {successCount}; failed: {failedCount}"); | ||
|
|
||
| return _jobStatusLogger.ToString(); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,12 +16,15 @@ public class SqlDataExecutor : IDataExecutor | |
| { | ||
| private readonly ILogger<SqlDataExecutor> _logger; | ||
| private readonly string _connectionString; | ||
| private readonly int _commandTimeout; | ||
|
|
||
| public SqlDataExecutor( | ||
| IOptions<NotFoundHandlerOptions> options, | ||
| ILogger<SqlDataExecutor> logger) | ||
| { | ||
| _connectionString = options.Value.ConnectionString; | ||
| // SqlCommand.CommandTimeout throws on a negative value; clamp so a misconfiguration can't break every query. (0 = no timeout.) | ||
| _commandTimeout = Math.Max(0, options.Value.CommandTimeout); | ||
| _logger = logger; | ||
| } | ||
|
Comment on lines
21
to
29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The It is safer to use defensive guards and clamp negative values to a sensible default (such as public SqlDataExecutor(
IOptions<NotFoundHandlerOptions> options,
ILogger<SqlDataExecutor> logger)
{
_connectionString = options?.Value?.ConnectionString ?? throw new ArgumentNullException(nameof(options));
// Clamp negative values to a default of 30 seconds instead of 0 (infinite timeout) to prevent hanging queries.
_commandTimeout = (options?.Value?.CommandTimeout >= 0) ? options.Value.CommandTimeout : 30;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
} |
||
|
|
||
|
|
@@ -42,6 +45,12 @@ public DataTable ExecuteQuery(string sqlCommand, params IDbDataParameter[] param | |
| _logger.LogError(ex, | ||
| "An error occurred in the ExecuteSQL method with the following sql: {SqlCommand}", | ||
| sqlCommand); | ||
|
|
||
| // Previously the exception was swallowed here and execution fell through to | ||
| // 'return ds.Tables[0]'. On a failed Fill the DataSet has no tables, so that line | ||
| // threw IndexOutOfRangeException ("Cannot find table 0"), masking the real cause | ||
| // (e.g. a command timeout). Rethrow so callers see the actual failure. | ||
| throw; | ||
| } | ||
|
|
||
| return ds.Tables[0]; | ||
|
|
@@ -199,10 +208,11 @@ private static SqlParameter CreateReturnParameter() | |
| return parameter; | ||
| } | ||
|
|
||
| private static SqlCommand CreateCommand(SqlConnection connection, string sqlCommand, params IDbDataParameter[] parameters) | ||
| private SqlCommand CreateCommand(SqlConnection connection, string sqlCommand, params IDbDataParameter[] parameters) | ||
| { | ||
| var command = connection.CreateCommand(); | ||
| command.CommandText = sqlCommand; | ||
| command.CommandTimeout = _commandTimeout; | ||
|
|
||
| if (parameters != null) | ||
| { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While this default interface implementation provides source compatibility, calling it repeatedly in a loop (as done in$O(N^2)$ performance trap for any third-party implementations that do not override this method.
RegisterMovedContentRedirectsJob.Execute) creates anEach call to
GetAllMoved(skip, take)will invokeGetAllMoved()to load the entire dataset from the database, only to skip and take a small batch in memory. For large tables, this will cause massive database load and memory allocations.Consider documenting this performance implication clearly, or changing the job's execution flow to stream the results of
GetAllMoved()directly and batch them in-memory usingEnumerable.Chunkif the loader does not support native paging.