Fix infinite loop and inert batch size in the persistent expired-quote cleanup cron - #41173
Open
TuVanDev wants to merge 1 commit into
Open
Fix infinite loop and inert batch size in the persistent expired-quote cleanup cron#41173TuVanDev wants to merge 1 commit into
TuVanDev wants to merge 1 commit into
Conversation
…cleanup cron Fix three related defects in the persistent_clear_expired cron job introduced by the CleanExpiredPersistentQuotes cleanup: - The batch cursor ($lastProcessedId) only advanced on a successful delete. If every row in a batch failed to delete (e.g. a foreign key from another table blocking the delete), the cursor never moved, the next iteration re-selected the identical set, and the while(true) loop never terminated. The cursor now advances unconditionally after each row is visited. - getExpiredPersistentQuotes() applied setOrder()/setPageSize() to an intermediate collection whose Select is only used to build a sub-select of matching IDs; the collection actually returned and iterated received neither an ORDER BY nor a LIMIT, so every batch loaded the store's entire expired-quote backlog regardless of $batchSize. The order and limit are now applied to the collection that is returned. ExpiredPersistentQuotesCollectionTest is updated to stub the new order()/limit() calls on that collection's Select. - The catch block logged the full stringified exception (including its stack trace) once per failed row via (string)$e; it now logs only $e->getMessage(). The unconditional cursor advance and the real LIMIT must ship together with a deterministic ORDER BY: without it, $lastProcessedId becomes the last row visited rather than the true maximum, and rows between the two are skipped on every future run.
|
Hi @TuVanDev. Thank you for your contribution!
Allowed build names are:
You can find more information about the builds here For more details, review the Code Contributions documentation. |
TuVanDev
added a commit
to TuVanDev/mageos-magento2
that referenced
this pull request
Aug 30, 2026
…cleanup cron The batch cursor (lastProcessedId) only advanced on a successful delete. If every row in a batch failed to delete (e.g. a foreign key from another table blocking the delete), the cursor never moved, the next iteration re-selected the identical set, and the while(true) loop never terminated. The cursor now advances unconditionally after each row is visited. The catch block also logged the full stringified exception (including its stack trace) once per failed row; it now logs only the exception message. Same fix submitted upstream to magento/magento2#41173.
TuVanDev
added a commit
to TuVanDev/mageos-magento2
that referenced
this pull request
Aug 30, 2026
getExpiredPersistentQuotes() applied setOrder()/setPageSize() to an intermediate collection whose Select is only used to build a sub-select of matching entity IDs; the collection actually returned and iterated received neither an ORDER BY nor a LIMIT, so every batch loaded the entire expired-quote backlog for the store regardless of batchSize. The order and limit are now applied to the collection that is returned. A LIMIT cannot live inside the IN (...) union sub-select on MySQL, which is why it is applied to the outer collection. This must ship together with the unconditional cursor advance in the companion commit, and the ORDER BY is not optional: with a real LIMIT and an unconditional cursor but no deterministic order, lastProcessedId becomes the last row visited rather than the true maximum, and rows in between are skipped permanently. Today that cannot happen only because the missing LIMIT makes every batch the whole set. Same fix submitted upstream to magento/magento2#41173.
rhoerr
pushed a commit
to mage-os/mageos-magento2
that referenced
this pull request
Aug 31, 2026
…ron (#333) * fix: prevent infinite loop and unbounded batches in persistent quote cleanup cron The batch cursor (lastProcessedId) only advanced on a successful delete. If every row in a batch failed to delete (e.g. a foreign key from another table blocking the delete), the cursor never moved, the next iteration re-selected the identical set, and the while(true) loop never terminated. The cursor now advances unconditionally after each row is visited. The catch block also logged the full stringified exception (including its stack trace) once per failed row; it now logs only the exception message. Same fix submitted upstream to magento/magento2#41173. * fix: apply order and limit to the returned expired-quote collection getExpiredPersistentQuotes() applied setOrder()/setPageSize() to an intermediate collection whose Select is only used to build a sub-select of matching entity IDs; the collection actually returned and iterated received neither an ORDER BY nor a LIMIT, so every batch loaded the entire expired-quote backlog for the store regardless of batchSize. The order and limit are now applied to the collection that is returned. A LIMIT cannot live inside the IN (...) union sub-select on MySQL, which is why it is applied to the outer collection. This must ship together with the unconditional cursor advance in the companion commit, and the ORDER BY is not optional: with a real LIMIT and an unconditional cursor but no deterministic order, lastProcessedId becomes the last row visited rather than the true maximum, and rows in between are skipped permanently. Today that cannot happen only because the missing LIMIT makes every batch the whole set. Same fix submitted upstream to magento/magento2#41173. * test: stub order() and limit() for the returned collections Select mock ExpiredPersistentQuotesCollectionTest configures three consecutive getSelect() return values but never stubs where()/order()/limit() on the third one (the Select for the collection actually returned). That was harmless before this fix because the source only called where() on it and discarded the result; the previous commit now chains order()/limit() onto that same call, so the mock needs behavior configured for all three or the chained calls run against an unconfigured (null) return value.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description (*)
The
persistent_clear_expiredcron job (Magento\Persistent\Observer\ClearExpiredCronJobObserver→Magento\Persistent\Model\CleanExpiredPersistentQuotes) cleans up expired persistent quotes in batches (defaultbatchSize500, set inMagento_Persistent/etc/di.xml). It has three related defects:Unbounded loop. In
processStoreQuotes(),$lastProcessedIdis only advanced whenquoteRepository->delete($quote)succeeds:getExpiredPersistentQuotes()selectsmain_table.entity_id > $lastProcessedId. If every delete in a batch throws (for example, a foreign key from another table blocking the delete), the cursor never advances, the next iteration re-selects the identical batch, andwhile (true)never terminates. This PR moves the assignment above thetry, unconditional, so the cursor always advances past a row once it has been visited, whether or not the delete succeeded.batchSizehas no effect on the query. InExpiredPersistentQuotesCollection::getExpiredPersistentQuotes(),setOrder()andsetPageSize()are applied to$additionalQuotes, but that collection'sSelectis only cloned to build a sub-select of matching entity IDs. The collection actually returned and iterated ($quotes) receives only aWHERE ... IN (...)and neither anORDER BYnor aLIMIT:Every batch therefore loads the store's entire expired-quote backlog in one query, regardless of
$batchSize. This PR adds the order and limit to the collection that is returned:(The
LIMITcan't live inside theIN (...)union sub-select on MySQL, which is why it has to sit on the outer collection.)These first two fixes must ship together, and the
ORDER BYis not optional. With a realLIMITand an unconditional cursor but no deterministic order,$lastProcessedIdbecomes the last row visited in an arbitrary batch rather than the true maximum, and rows between the two are silently skipped forever. Today that can't happen only because the missingLIMITmakes every batch the whole remaining set.Log volume. The catch block logs
(string)$e, which stringifies the whole exception including its stack trace, once per failed row. Changed to$e->getMessage().Related Pull Requests
None.
Fixed Issues (if relevant)
N/A — no existing GitHub issue; reproducible directly on
2.4-develop(see manual testing scenarios).Manual testing scenarios (*)
This reproduces with core code only; no third-party extension is required. The general condition is anything that makes
QuoteRepository::delete()throw for the highest-id expired persistent quote in a store — here, a foreign key from a custom table referencingquote.entity_idwithON DELETE NO ACTION.Enable persistence (Stores → Configuration → Customers → Persistent Shopping Cart) and create a quote that the cron will select as expired:
is_persistent = 1,updated_atolder than the configured persistence lifetime, and matching thecustomer_logconditions ingetExpiredPersistentQuotes()(logged in and out, or logged in with an expired session). Note itsentity_id, e.g.123.Create a table with a blocking foreign key to that quote, and a row referencing it:
Run the cron job (
bin/magento cron:run --group default, or invokeCleanExpiredPersistentQuotes::execute($websiteId)directly).quoteRepository->delete()throws an integrity-constraint violation for quote 123 on every pass.$lastProcessedIdnever advances past its value from before quote 123, sogetExpiredPersistentQuotes()re-selects the same batch (quote 123 and everything after it) every iteration. The job never terminates — it has to be killed — and the log fills with a full stack trace per iteration.$lastProcessedIdadvances past quote 123 regardless of the delete failing, the loop makes progress through the rest of the batch and terminates once each store's expired quotes are exhausted, quote 123 is the only one left behind (by design, since it's genuinely undeletable), and the log records one single-line message for it, not one stack trace per iteration.To confirm the
LIMIT/ORDER BYspecifically: with several dozen expired persistent quotes present and a query logger (or Xdebug breakpoint) ongetExpiredPersistentQuotes(), compare the emitted SQL before and after — before, there is noLIMITclause and the full expired set is fetched in one query; after, the query includesORDER BY main_table.entity_id ASC LIMIT <batchSize>.Clean up:
DROP TABLE quote_reference_test;Questions or comments
ExpiredPersistentQuotesCollectionTest::testGetExpiredPersistentQuotesmocks the collection'sSelectobject for the returned collection but doesn't stubwhere()/order()/limit()on it (harmless before this change, since the original code never used that return value). This PR adds the missing stubs so the existing test continues to pass against the neworder()/limit()calls; happy to adjust if maintainers prefer a different test shape.Contribution checklist (*)
CleanExpiredPersistentQuotesTestandExpiredPersistentQuotesCollectionTestcover the changed methods; the latter is updated for the neworder()/limit()calls.php -lon all changed files and by manual trace of the existing unit tests against the diff.