Page the "Register content move redirects" job so it survives large tables (6.x backport of #188)#193
Conversation
The "Register content move redirects" job loaded the entire NotFoundHandler.ContentUrlHistory table in one unbounded query (GetAllMoved -> .ToList()). On large tables that SELECT exceeds the SqlClient command timeout, and SqlDataExecutor.ExecuteQuery swallowed the exception then returned ds.Tables[0] on an empty DataSet, surfacing a misleading "Cannot find table 0" instead of the real timeout. - SqlDataExecutor: apply a configurable CommandTimeout and rethrow query failures instead of masking them as "Cannot find table 0". - NotFoundHandlerOptions.CommandTimeout (default 30s). - IContentUrlHistoryLoader/SqlContentUrlHistoryRepository: add a paged GetAllMoved(skip, take) using OFFSET/FETCH over the moved keys, and make the parameterless overload stream pages so the unbounded query is gone. - RegisterMovedContentRedirectsJob: process the table page-by-page, tunable via OptimizelyNotFoundHandlerOptions.MovedContentBatchSize (default 1000). - Tests for repository paging and job batching; README updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hangelog Review feedback on PR Geta#188 (thanks @ivanmarkovic1402): - RegisterMovedContentRedirectsJob: clamp MovedContentBatchSize to >= 1. A misconfigured 0/negative would page with "FETCH NEXT 0 ROWS ONLY" (invalid SQL) and, now that ExecuteQuery rethrows, fail the whole job. - IContentUrlHistoryLoader.GetAllMoved(skip, take) is now a default interface implementation (pages in-memory over GetAllMoved()), so the addition no longer breaks third-party implementers. Data-backed stores (SqlContentUrlHistoryRepository) still override it with the OFFSET/FETCH query. - CHANGELOG: document the new options (CommandTimeout, MovedContentBatchSize), the paged GetAllMoved, and the ExecuteQuery rethrow behavior change. - Tests: guard test for the batch-size clamp and an exact-multiple-of-batch case (one trailing empty fetch, then stop). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- SqlContentUrlHistoryRepository.GetAllMoved(skip, take): short-circuit to empty when take <= 0 (FETCH NEXT 0/negative ROWS is invalid SQL) and clamp a negative skip, so a direct caller can't push invalid SQL to the database. - SqlDataExecutor: clamp a negative CommandTimeout to 0; SqlCommand.CommandTimeout otherwise throws and would break every query. Documented 0 = no timeout. - Tests: cover the non-positive take guard; drop an xUnit2013 warning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces database paging and command timeout configurations to prevent unbounded queries and timeouts when processing large NotFoundHandler.ContentUrlHistory tables. Feedback highlights a potential O(N^2) performance trap in the default interface implementation of GetAllMoved(skip, take) for third-party implementations, and recommends adding defensive null checks for options. Additionally, it is advised to clamp negative command timeouts to a sensible default (e.g., 30 seconds) instead of 0 to avoid infinite timeouts that could cause queries to hang.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| IEnumerable<(string contentKey, IReadOnlyCollection<ContentUrlHistory> histories)> GetAllMoved(int skip, int take) | ||
| => GetAllMoved().Skip(skip).Take(take); |
There was a problem hiding this comment.
While this default interface implementation provides source compatibility, calling it repeatedly in a loop (as done in RegisterMovedContentRedirectsJob.Execute) creates an
Each call to GetAllMoved(skip, take) will invoke GetAllMoved() 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 using Enumerable.Chunk if the loader does not support native paging.
| 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; | ||
| } |
There was a problem hiding this comment.
The options parameter or options.Value could be null, which would cause a NullReferenceException when accessing options.Value.MovedContentBatchSize. It is safer to use defensive guards or null-coalescing operators to handle this.
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 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; | ||
| } |
There was a problem hiding this comment.
The options parameter or options.Value could be null, which would cause a NullReferenceException when accessing options.Value.ConnectionString or options.Value.CommandTimeout. Additionally, clamping a negative CommandTimeout to 0 means that any negative misconfiguration will result in an infinite timeout (no timeout), which can cause database queries to hang indefinitely in the event of deadlocks or severe database contention.
It is safer to use defensive guards and clamp negative values to a sensible default (such as 30 seconds) instead of 0.
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));
}
Backport of #188 to the CMS 12 / .NET 8 line
This is the
release/v6backport of #188 ("Page the 'Register content move redirects' job so it survives large tables"), which was merged intomasterand released as v7.1.0 (CMS 13 / .NET 10). Per @valdisiljuconoks's request on #188 to open a PR against the CMS 12 line, this targets the newrelease/v6branch. The code change is identical to what shipped in 7.1.0 — the src files merged clean across the CMS 13 rebase, so this is the same diff applied on top ofv6.0.0, built and tested on .NET 8. Only the CHANGELOG lineage differs (6.x, no 7.0.0 entry).Problem
The [Geta NotFoundHandler] Register content move redirects job loads the entire
NotFoundHandler.ContentUrlHistorytable in a single unbounded query before doing any work:GetAllMoved()issues a self-join +GROUP BY ... HAVING COUNT(*) > 1over the whole table with no paging. On sites with a large history table thisSELECTexceeds the SqlClient command timeout (the default 30s, which was never overridden).SqlDataExecutor.ExecuteQuerythen catches and swallows the timeout, logs it, and falls through toreturn ds.Tables[0]— but on a failedFilltheDataSethas no tables, so that line throwsIndexOutOfRangeException: "Cannot find table 0". The operator sees a confusing "Cannot find table 0" with no hint that the real cause is a query timeout, and the job never completes.Fix
SqlDataExecutor: apply a configurableCommandTimeoutto every command, and rethrow query failures instead of masking them as "Cannot find table 0". A negativeCommandTimeoutis clamped to0(SqlClient otherwise throws);0means no timeout.NotFoundHandlerOptions.CommandTimeout(seconds, default30) so large sites can raise it past the SqlClient default.IContentUrlHistoryLoader/SqlContentUrlHistoryRepository: add a pagedGetAllMoved(int skip, int take)usingOFFSET ... FETCH NEXTover the moved keys, and reimplement the parameterlessGetAllMoved()to stream pages so the unbounded query is gone everywhere. (md5_ContentKeyis the hash ofContentKey, so each key maps to a single group and is never split across a page boundary.) The paged query short-circuits to an empty result whentake <= 0and clamps a negativeskip, so a bad caller can't emit invalid SQL.RegisterMovedContentRedirectsJob: process the table page-by-page (stoppable, with progress logging) instead of materialising it all up front. Page size is tunable viaOptimizelyNotFoundHandlerOptions.MovedContentBatchSize(default1000) and is clamped to>= 1.CreateRedirectsonly writes to the redirects store, notContentUrlHistory, so the moved set is stable for the run and skip-based paging never skips a key.Compatibility notes
IContentUrlHistoryLoader.GetAllMoved(int skip, int take)ships as a default interface method (pages in-memory overGetAllMoved()), so existing third-party implementations keep compiling without any change. Data-backed stores should override it with a server-side paged query, asSqlContentUrlHistoryRepositorydoes — the in-memory default still materialises the full set, so it is a source-compatibility shim, not a scalability path.SqlDataExecutor.ExecuteQuerynow propagates exceptions that were previously swallowed. This is intentional (so failures surface instead of becoming "Cannot find table 0"), but it changes behaviour for any caller that relied on getting an empty table back on error. The 404 request hot path is unaffected: redirect lookups during a request read the in-memoryCustomRedirectCollection(CustomRedirectHandler.Find), not a live query. The change affects collection (re)loads and admin/reporting reads, which now surface a real error instead of silently returning nothing.Tests
skip/take, groups histories by content key, stops paging on a short page, and returns empty without querying whentake <= 0.Built and tested on .NET 8: 60 pass in
Geta.NotFoundHandler.Tests, 60 pass inGeta.NotFoundHandler.Optimizely.Tests, 0 failures.Generated with Claude Code