Page the "Register content move redirects" job so it survives large tables#188
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
|
Friendly bump 👋 @wezz @marisks — would you be able to take a look at this when you have a moment? Context: the "Register content move redirects" job runs an unbounded Two compat notes I'd value your steer on:
Glad to rebase or adjust to fit your conventions. Thanks! |
There was a problem hiding this comment.
Thanks for this - it's a well-scoped fix for a real problem, and the write-up (including the root cause of the misleading "Cannot find table 0") is excellent.
I checked it against the code and the core correctness argument holds: CreateRedirects only writes to the redirects store and never touches NotFoundHandler.ContentUrlHistory, so the moved set is stable for the run and skip-based paging can't skip or duplicate a key. Paging by grouped keys (not rows) is the right call, and since md5_ContentKey is derived from ContentKey, a key can't be split across a page boundary. CommandTimeout defaults to 30s (the SqlClient default), so there's no behavior change unless configured, and the ExecuteQuery rethrow is strictly better than the previous behavior. Tests are solid.
A few things I'd like to see before merge, none of them architectural:
- Clamp
MovedContentBatchSizeto >= 1 - a misconfigured0/negative value emits invalidFETCH NEXT 0SQL and now fails the job. - Make the interface change deliberate and bump the version. Adding
GetAllMoved(int, int)toIContentUrlHistoryLoaderbreaks third-party implementers; a default interface implementation (you're on .NET 10) keeps it source-compatible, or keep the paged query on the repository. This plus theExecuteQueryrethrow warrants a CHANGELOG entry and an appropriate semver bump. - CHANGELOG entry for the new options (
CommandTimeout,MovedContentBatchSize) and the two behavior changes (theIContentUrlHistoryLoaderaddition andExecuteQuerynow propagating exceptions). The PR updatesREADME.mdbut notCHANGELOG.md.
One non-blocking follow-up: the paged query re-aggregates the whole table on every page and the OFFSET grows with each page, so total DB work scales with page count. It fixes the timeout, but materializing the moved-key set once (or keyset pagination over the indexed md5_ContentKey) would scale better on very large tables — happy for that to be a separate PR.
Overall the approach is sound and I'm in favor of merging once the batch-size clamp, the interface/versioning decision, and the CHANGELOG are addressed.
…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>
|
Thanks for the thorough review @ivanmarkovic1402 — much appreciated. Pushed 966068e addressing all three blockers:
Also added the two tests you flagged: a guard test for the batch-size clamp and an exact-multiple-of-batch-size case (one trailing empty fetch, then stop). All 58 Optimizely tests pass. On the non-blocking perf point (per-page re-aggregation + growing |
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>
966068e to
0668852
Compare
|
Rebased onto current Pushed a small hardening commit on top:
All tests pass on .NET 10: 60 in I also tidied the PR description to clarify two points that could read ambiguously: the The performance follow-up (temp table or keyset pagination over the indexed |
|
Thanks for merging this, @ivanmarkovic1402 🙏 One follow-up: since the fix released in v7.1.0, it's only available on the CMS 13 / .NET 10 line. Teams still on Optimizely CMS 12 / .NET 8 are on the Would you consider backporting #188 to a Happy to open the backport PR myself if that would help — just let me know if you'd like a 6.x branch to target. |
|
Please, open PR against CMS12 version, we would merge it in. CMS12 version is not primary target for us anymore, but we can maintain it in the background while there is active usage for it. Thx! |
|
Thanks @valdisiljuconoks — really appreciate you keeping the CMS 12 line maintained. 🙏 I've already reconstructed the backport on the One thing I need from you to open it: there doesn't seem to be a CMS 12 / Thanks again! |
|
@kennygutierrez branch name "release/v6" |
|
Thanks @valdisiljuconoks! Opened the 6.x backport against |
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.Rebased onto
master(Optimizely CMS 13.1 / Commerce 15 / .NET 10, #187). All tests pass on the new target framework: 60 inGeta.NotFoundHandler.Tests, 60 inGeta.NotFoundHandler.Optimizely.Tests.Generated with Claude Code