Commit 79b7e712965 for woocommerce
commit 79b7e712965924d2c853c55ba9f6bcc87098e736
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date: Tue Sep 22 11:54:59 2026 +0300
Fix scheduled-sales cron exhausting memory before it saves anything (#68016)
* fix(products): process scheduled-sale products in batches
wc_scheduled_sales() primes the post cache for the whole result set before
saving anything. On a store with a large backlog the request can exhaust memory
before the first save, so the run dies having made no progress at all, and the
next run starts from the same place.
Prime and release per batch of 50 instead. Peak memory then tracks the batch
rather than the backlog, so the loop makes forward progress even when it cannot
finish. Release only what the batch primed: posts and post_meta key on the
object ID and are shared with every other job in the same WP-Cron request, so
they are deleted by ID rather than flushed. The product caches namespace their
keys, leaving no key to delete per ID, so those groups go as a whole, and the
persistent-capable ones only when the cache lives in this request.
Variations remain the exception. Saving one queues its parent for the deferred
sync that WC_Post_Data::do_deferred_product_sync() drains at shutdown, outside
this loop and without batching, so a large variation backlog still grows there.
Refs #63773
* test(products): cover batch completeness and shared-cache release
The batching change had no test. Two invariants are worth pinning, both checked
against mutants rather than only for a green run.
A backlog spanning more than one chunk must be processed whole, and the hooks
must still fire once with the entire set rather than once per chunk. Extensions
read that payload, so fragmenting it would be a contract change hidden inside a
memory fix. Processing only the first chunk fails this test.
Releasing a batch must not evict entries belonging to anything else. The loop
shares `posts` and `post_meta` with every post type and every other job in the
same WP-Cron request, which is why it deletes its own IDs instead of flushing
those groups. Swapping either delete for a group flush fails this test, which
primes an unrelated page and its meta and asserts both survive the run.
Refs #63773
* fix(products): screen malformed IDs out of the batch cache release
Batching the scheduled-sales loop added a per-batch cache release built on
wp_cache_delete_multiple() and clean_object_term_cache(). Both use the IDs as
array keys, so a non-scalar value is an "Illegal offset type" TypeError.
The product data store is replaceable through woocommerce_product_data_store,
and nothing enforces the int[] its query methods document. Before batching, such
a value reached only _prime_post_caches(), which coerces it and reports through
_doing_it_wrong(). Now it kills the run, discarding the products already saved in
earlier batches, which is the failure this loop was changed to avoid.
Screen the IDs used for the release on the same rule _get_non_cached_ids()
applies when priming, so the release set can only name entries the batch could
have created. A looser test evicts posts that were never primed: absint() maps
-5 onto 5, and a cast turns true and '3abc' into 1 and 3. The loop variable is
left alone, since wc_get_product() and the transient cleanup already tolerate a
malformed ID.
Cover both the guard and the release itself. Deleting the entire release block
previously left every test passing, so the memory relief this loop exists to
provide was unverified.
Refs #66720
* fix(products): stop flushing the terms cache once per batch
The batch cache release flushed the terms group alongside products and
term-queries. That group keys by term ID and is bounded by the size of the
taxonomy, not by the backlog, so it was never this loop's to release: nothing
in it grows as the run works through more products.
Flushing it per batch therefore bought no headroom against the failure this
loop was changed to avoid, and cost a re-query of the same category, tag and
attribute terms every fifty products. Priming had just paid for them.
Leave the group resident and say why, so the next reader does not add it back.
Refs #66720
* docs(products): correct why product_objects is flushed as a group
The comment said the product caches have no per-ID key to delete, because
their keys carry a random prefix. ObjectCache::remove() disproves that: it
reads the current prefix back and deletes the live key, and the product data
store already takes that route.
The flush still earns its place, just for a different reason. Saving a product
releases its own entry through ProductCacheController, so the entries left
over are the ones this loop reads without saving.
State that reason instead, so nobody trusts the false one when deciding how to
release this group.
Refs #66720
* docs(products): scope the batching memory claim to what it releases
The docblock said batching holds this loop's memory flat so it no longer
scales with the backlog. It does not go that far.
Reading a product caches its type under a group named for that product, one
per product, which the release block never touches. The shared groups it does
touch are released only when the object cache lives in this request, and a
backend without flush_group support gets none of them released at all.
Claim what the batching delivers, which is enough for a run that cannot finish
to still make progress, and name the paths that keep growing. The variations
caveat was already here; these belong next to it.
Refs #66720
* style(products): put the batching docblock on the closure it documents
The docblock carries @param lines for $process_products, but three cache
capability assignments sat between it and the closure, so it read as
documentation for those instead.
Move the assignments above it. They are computed once and depend on neither
the batch nor the mode, so they are just as much at home there.
Refs #66720
* docs(products): name the cache the batching actually leaves behind
The partial-relief note said reading a product caches its type in a per-product
group that nothing releases. Half right, and the half it got wrong is the half
that matters: the type value goes to the shared products group, which this
closure already flushes.
What does sit in a group of its own is the cache prefix, one group per product
named for that product, and no configuration releases it. Say that instead.
Trim the rest while here. The docblock had grown to three paragraphs restating
a rationale the release block already gives at the point it applies, against
the 3-4 lines AGENTS.md asks for.
Also correct the term-queries note: update_object_term_cache() fetches a whole
chunk in one call and stores one entry, not one per product.
Refs #66720
* docs(products): say what the product_objects gate costs when it is false
The comment explained why flushing product_objects is safe, but not what
happens on a drop-in that does not implement flush_group, where the guard is
false and the group is never released for the whole run.
That case is worth naming because product_objects is the one group here with
no other release path, and because product instance caching is on by default
since 11.0.0, so what stays resident is a whole WC_Product per product read.
A maintainer reading only the true branch would not know the relief is
conditional on drop-in capability.
Refs #66720
* docs(products): say why products is flushed whole where posts is not
Four lines above, the release block states that posts and post_meta are
deleted per ID because they are shared and must not be flushed whole. It then
flushes products whole without saying what makes that different, which reads as
the block contradicting itself.
Three things make it different: products is WooCommerce's own group rather than
one every post type shares, the guard means it is only reached when the object
cache is request-local, and its keys carry a per-product random prefix, so a
scoped delete would have to rebuild that prefix for every ID.
Say so, so the asymmetry reads as a decision rather than an oversight.
Refs #66720
* docs(products): gate the product_objects claim on the feature that makes it true
The comment said wc_get_product() caches every product it reads. It does that
only when product_instance_caching is enabled: WC_Product_Factory::get_product()
reads and writes ProductCache behind FeaturesUtil::feature_is_enabled().
The feature is on for installs from 11.0.0 but off on plenty of existing
stores, and there the flush this comment justifies does nothing at all. Say
which case the reasoning describes, and say what the other case costs.
Refs #66720
* docs(products): drop the claim that starting a sale here schedules its end
The note said wc_apply_sale_state_for_product() writes sale date meta on
'start', firing wc_maybe_schedule_sale_events_on_meta_change() and scheduling
the end Action Scheduler event. None of that happens on this path.
Both modes change only the price prop. handle_updated_props() syncs sale date
meta only when date_on_sale_from, date_on_sale_to, regular_price, sale_price or
product_type changed, so no sale date key is ever written, and the meta-change
handler returns early on the first line of its guard.
Say what the path actually does, including the part that matters: a sale this
run starts is ended by a later run of the same cron, not by a per-product event.
Refs #66720
* docs(products): type the ID list as what the data store actually returns
The closure documented $product_ids as int[]. Both query methods behind it
return $wpdb->get_col(), which yields numeric strings, and the release screen
a few lines down exists precisely to handle that: its is_string() branch is the
one real traffic takes, not a defensive corner.
Documenting the list as int[] invites a reader to conclude that branch is dead
and delete it, which is the mutation the malformed-ID test was written to catch.
Name the real shape, and name why a replaced store makes it unenforceable.
Refs #66720
* perf(products): slice each batch instead of chunking the whole backlog first
array_chunk() builds every batch before the loop runs its first one, so the
call allocated in proportion to the backlog at the one moment this loop exists
to keep flat: before a single product has been saved.
Measured on 500,000 IDs, array_chunk() added 26MB over the list itself. Slicing
one batch per iteration added nothing measurable.
That is the failure this function was rewritten to avoid, just moved a few
lines earlier, and it is the part a 2,000-product test is too small to show.
Refs #66720
* fix(products): release term caches under each ID's real post type
_prime_post_caches() keys term relationships on the post type it reads for each
ID. The release passed 'product' for the whole batch, so the two stopped
matching as soon as a batch held anything else.
The sale queries filter on postmeta alone, so variations reach this loop, and a
replaced data store can return any post at all. For those IDs the release
cleaned the taxonomies registered for 'product' rather than the ones priming had
actually populated, leaving anything the real type carries alone. Core survives
this because every variation taxonomy is also a product taxonomy, so the wrong
set happens to be a superset. An extension registering a taxonomy on variations
alone gets no such luck. Callbacks on the public clean_object_term_cache hook
were told the wrong type either way.
Group the IDs by the type priming saw and release each group under it. The
lookup sits directly after the prime, where get_post_type() is a cache hit:
measured at zero queries for a full batch. It cannot move below the loop, since
saving evicts the entries it reads.
Refs #66720
* fix(products): release term caches against the same union priming wrote
Releasing each ID under its own post type looked like the careful reading of
what priming did. It was not. _prime_post_caches() resolves the batch's post
types and then caches every ID against the union of their taxonomies, writing
an empty entry wherever an ID has no terms in one, so per-type cleanup cannot
reach the entries the other types brought in.
On a batch holding a product and a variation, the variation kept four:
product_brand, product_type, product_cat and product_tag. Those grow with the
variation backlog, which is the leak this loop exists to close.
clean_object_term_cache() derives its taxonomies exactly as priming does, so
handing it the same type list clears the same union. That also keeps the fix
the grouping was for, since the list is what the batch actually holds rather
than a hardcoded 'product'.
Cover it with a mixed product/variation batch asserting no relationship group
survives, which fails against the per-type form on the first of the four.
Refs #66720
* fix(products): loop the term release one post type per call
Handing clean_object_term_cache() the whole type list clears the right union,
because get_object_taxonomies() casts to array on both sides. It also forwards
that value untouched into do_action( 'clean_object_term_cache', ... ), whose
signature is a single string, and every call site in core passes one. Core's
own multi-type path loops for exactly this reason.
A listener that type hints the parameter string fatals on an array, which would
end a cron run mid-batch: the failure this loop exists to prevent. One that
compares it to 'product' would quietly stop matching instead.
Loop instead. Each pass clears one type's taxonomies for the whole batch, so
the union is unchanged and no listener ever sees a non-string.
Refs #66720
* docs(products): name the guard that actually blocks the sale date write
The note credited handle_updated_props() with skipping the sale date meta when
only the price prop changed. That method never writes those keys at all; it
writes _sale_price and _price.
The gate is get_props_to_update(), which queues a meta key only when its prop
appears in get_changes(), or when the key is missing outright. A product the
sale queries matched already carries its dates, so neither branch fires and the
keys stay untouched.
Same conclusion, right mechanism. The old one pointed a future reader at a
method they could edit freely without affecting any of this.
Refs #66720
* test(products): give the cache test back its docblock
Inserting the mixed-batch test between the docblock and the function it
described left two docblocks stacked on the new test and none on
test_wc_scheduled_sales_leaves_unrelated_post_caches_intact().
phpcs fails the file on the missing comment, so CI Lint would have caught it,
but only after the push.
Refs #66720
* docs(products): scope the mirror claim to the prime it describes
The screening comment said the release set matches what priming could have
created. That holds for the posts prime, which runs its IDs through
_get_non_cached_ids() and the same validation this screen copies.
The other two primes do not work that way. update_meta_cache() and
update_object_term_cache() both intval() the raw chunk with no validation, so a
row like "12abc" primes meta and term relationships under post 12 while the
screen drops it, and those entries outlive the batch.
Say which prime the claim covers and name what the other two leave behind. It
takes a replaced data store to reach, and it is bounded by the malformed rows
rather than the backlog, but the comment should not read as a guarantee across
all three.
Refs #66720
* docs(products): cut the batching comments back to what the code cannot say
Four rounds of review corrected this function, and each correction was appended
rather than folded in, so the block reached 52 lines of comment against 36 of
code. Most of the excess argued against approaches that were tried and rejected
along the way, which is review history, not documentation.
Keep the invariants a reader cannot recover from the code: that the release has
to mirror the union priming wrote, that the types must be read before the save
loop evicts them, that the terms group is left resident deliberately, and that
one type per call is what keeps a public action's payload a string. Drop the
rest, including a mangled line left mid-sentence by an earlier edit.
Eighteen lines now. No code changed.
Refs #66720
* test(products): cover the batched prime and the external-cache gating
Two branches carried no test. Reverting the prime to the whole backlog passed
all 87 of them, which left the claim this change exists to make entirely
unverified, and nothing exercised the guard that keeps the persistent-capable
groups out of the release.
The prime is checked from inside the run, on the first save, by asking whether
the last ID in the queue is resident yet. At the end of a run every batch has
been primed and released, so the distinction is only visible mid-flight. The
fixtures have to be flushed first: creating them warms every row, which a cron
request would not have done.
The gating test sets an external cache for the duration, leaves a sentinel in
products and term-queries, and asserts both outlive a run that still settles
its product.
Both were checked against the mutants they exist for: priming the whole list
fails the first, dropping the wp_using_ext_object_cache() guard fails the
second.
Refs #66720
* refactor(products): keep the batch size in one place
Slicing per iteration split the batch size across two literals, the loop step
and the slice length. array_chunk() had taken it once. Change one and the loop
either steps over products it never processed or hands the same ones to a
second batch.
Name it once and use it for both.
Refs #66720
* test(products): stop the external-cache flag leaking out of its test
Nothing assigns $_wp_using_ext_object_cache unless an object-cache drop-in
loads, and the test suite runs without one, so the value captured before
switching it on is null rather than false. wp_using_ext_object_cache( null )
reads instead of writing, so the restore did nothing and every later test on
the process ran believing an external cache was in use.
Cast the captured value. Confirmed by adding a probe test after this one: it
fails without the cast and passes with it. Same fix as PR #65440.
Also assert the relationship caches are populated before the mixed-batch test
checks they are gone, so it cannot pass against a run that primed nothing.
Refs #66720
* docs(products): note the hook cache change in the changelog
The PR description says callbacks on the sale hooks no longer see a primed post
cache. Extension authors read release notes, not PR descriptions.
Name the four hooks and what changed for them.
Refs #66720
* fix(products): release term relationships without firing a mislabelled action
clean_object_term_cache() pairs the IDs it is given with the single type it is
given, and fires that pairing on a public action. A batch holds more than one
type, so any call here labels part of it wrongly: passing the whole list per
type reported the variation as a product and the product as a variation, and
partitioning by type missed the cross-type entries priming had written.
Neither is needed. save() already fires that action once per product with the
product's own type, so the signal is out; what is left is eviction. Delete the
relationship groups directly, over the same taxonomy union priming used.
That also drops the site-wide terms salt bump the helper performs once per
batch, which reached further than anything this loop primed.
Confirmed with a probe on the action: before, a mixed batch fired
[[1,product],[1,product_variation],[1,product],[2,product],[2,product_variation]],
the last two being this loop's mislabelled pairs. After, only the three
per-product firings remain.
Refs #66720
* docs(products): say why term-queries goes whole, and widen the changelog note
Two loose ends from review. The comment credited the random key prefix for both
whole-group flushes, which is products only: term-queries is keyed by a salt
this loop has no way to recompute.
The changelog said the sale hooks lose the post cache, and stopped there. The
after hooks also run once the batch has released the product object cache, which
is what wc_get_product() reads and what an extension callback is most likely to
touch.
Refs #66720
* fix(products): screen the batch once and use that list throughout
Priming reaches three caches that disagree about what an ID is. Only the posts
prime screens on _validate_cache_id(); update_meta_cache() and
update_object_term_cache() intval() whatever they are handed. Priming the raw
chunk therefore wrote meta and relationship entries under IDs the release list
excluded, and those outlived the batch.
Screen before priming instead, and prime, process and release from the one
list. The release then covers exactly what the prime wrote, which is what the
comment claimed all along.
Processing loses nothing. wc_get_product() already returned false for these:
its factory gates on is_numeric(), so "12abc" was never a product. What it
stops doing is calling delete_product_specific_transients() with the raw value,
which absint()ed it and cleared an unrelated product's transients.
Core no longer sees the malformed rows at all, so it no longer reports them
through _doing_it_wrong once per row. The test that asserted that notice now
documents its absence.
Refs #66720
* fix(products): point the transients docblock at the real WC_Product
ProductUtil sits in a namespace and imports no WC_Product, so the unqualified
name in this @param resolves to a class that does not exist there. The body has
always used the global one.
It went unnoticed while callers passed mixed. Handing it a typed int made
PHPStan check the union and fail.
Refs #66720
* fix(products): resolve batch IDs the way wc_get_product() does
Screening the batch on _validate_cache_id()'s rule answered the wrong question.
That rule asks whether a value is a well-formed cache key. The loop needs to
know whether it names a product, which is what wc_get_product() answers, and it
accepts more: anything is_numeric(), any WC_Product, any object carrying an ID.
So ' 12', '012', '+12', '12.0', 1e2 and a WP_Post all named a real product
before the screen went in and named nothing after it. Nothing reports that. The
hooks still fire with the full list, the transient is still cleared, the run
still looks successful, and because the queries match on unchanged meta the same
products are selected and dropped again on every run. A sale that never starts,
or never ends.
Only a replaced woocommerce_product_data_store can reach it, since get_col()
returns canonical strings, but that store is the reason the screen exists.
Resolve instead of screening, on wc_get_product()'s own terms, and keep the one
int list driving prime, process and release.
Refs #66720
* test(products): drop two comments the code no longer matches
One still said core reports the malformed rows through _validate_cache_id(),
directly above the comment explaining that it no longer sees them. The other
said objects are left out because intval() warns on them during priming, which
stopped being true once they were resolved rather than rejected.
Refs #66720
* test(products): pin the id forms a replaced data store may return
Nothing failed when the batch screened ids on whether they were well-formed
cache keys rather than on whether they named a product, so the drop it caused
could come back unnoticed. The malformed-id test only covers rows that are
genuinely garbage, which is why it stayed green through that.
Cover the other side: six products reported as a zero-padded string, with a
leading space, signed, as a decimal string, as a float, and as a WP_Post, all
of which wc_get_product() resolves. Each must settle.
Fails on the first row against the screen it exists to prevent returning to.
Refs #66720
* docs(products): type the batch closure param as what it resolves
The $process_products closure documented its input as string[]|int[],
yet its body branches on WC_Product instances and on objects that
expose an ID before it falls back to numeric strings. A reader, or a
tool that trusts the tag, sees those branches as dead code rather than
the pluggable-data-store contract they implement.
Widen the tag to the forms the closure resolves, on the same terms
wc_get_product() accepts, so the docblock and the body agree.
Refs #63773
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXXECbrQXzKbwBkJgrPBcH
* docs(products): say why the batch release skips clean_post_cache()
The comment on the posts and post_meta release explained why the
batch deletes its own IDs instead of flushing the groups, but not why
it does so with wp_cache_delete_multiple() rather than the
clean_post_cache() helper every other bulk release in the codebase
uses. Two reviewers read that as an oversight.
It is not: save() already ran clean_post_cache() for every product
that changed, and running it again for the rest would re-read each
post and bump the site-wide posts and terms salts for rows nothing
wrote. Say so, name the accepted trade-off that read-only entries are
evicted too, and name the runner correctly: this is a recurring
Action Scheduler action, so the co-tenants are the other actions in
the same queue-runner request.
Refs #63773
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXXECbrQXzKbwBkJgrPBcH
* docs(products): state the hooks' cache contract in the docblock
The changelog and the PR description both say that the four
wc_before/after_products_*_sales hooks now fire against an unprimed
and a released cache respectively. The function's own docblock, which
already describes when those hooks fire, did not, so an extension
author reading the source had to find the changelog to learn that a
callback iterating the payload now takes a cold read per ID.
Add the contract to the docblock next to the existing hook note.
Refs #63773
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXXECbrQXzKbwBkJgrPBcH
* test(products): pin the group flushes on a request-local cache
The external-cache test proves that products and term-queries survive
when wp_using_ext_object_cache() is true. Nothing proved the other
branch: that on the default object cache the loop actually flushes
products, term-queries and product_objects after each batch. Removing
all three wp_cache_flush_group() calls left every test green, and
those calls carry the memory relief the batching exists for.
Add the mirror test: seed a sentinel in each group, run the cron with
the cache request-local, and assert all three are gone. Skipped where
the cache lacks flush_group or is external, since the loop skips the
flushes there by design.
Refs #63773
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXXECbrQXzKbwBkJgrPBcH
* test(products): pin single processing of a duplicated id
The batch now runs array_unique() over its IDs, which is new
behaviour: a product the sales query returns twice used to be saved
twice and now settles once. The query can return duplicates, since it
joins postmeta without a row-identity constraint and a product with
two rows for one joined key comes back per row. No test exercised
that input, so the de-duplication could be dropped unnoticed.
Add a test whose replaced store reports one product twice, as an int
and as the string $wpdb->get_col() returns, and assert a single save.
Refs #63773
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXXECbrQXzKbwBkJgrPBcH
* fix(products): release product_objects entries by id, not a group flush
The batch release only ran wp_cache_flush_group('product_objects') when
wp_cache_supports('flush_group') was true. On a drop-in without that
support, the branch never ran, so every product a batch read but didn't
save kept its full WC_Product instance in memory for the rest of the run
— the exact failure mode this PR exists to fix, just narrower. The
codebase already has a fix for this: ProductCache::remove() reaches a
real wp_cache_delete() on every backend. ProductCache::flush() does not
— it only bumps a namespace prefix via wp_cache_set() and frees nothing,
so it isn't a substitute.
Release each batch id through ProductCache::remove() instead, gated on
the feature that populates the cache rather than on flush_group support.
Update the request-local-cache test to match: it no longer seeds and
checks a product_objects sentinel (that group can no longer be verified
via a whole-group flush), and a new test seeds a real cached product and
asserts it's released by id regardless of flush_group/external-cache
state.
Refs #63773
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7N7bET4QnBktZqjgMq9ta
* test(products): guard the clean_object_term_cache mislabeling fix
An earlier version of this branch released relationship caches by
calling clean_object_term_cache() once per post type with the whole
batch's id list, firing the public action with the wrong $object_type
for ids that didn't match that type. That was self-found and fixed by
deleting the relationship cache groups directly instead, but nothing in
the suite asserted the action itself — the mixed-batch test only checks
the cache keys end up empty, which a reintroduced buggy call would
produce identically.
Hook the action in the mixed-batch test and assert it never fires with a
mismatched post type. Confirmed as a mutant killer: reintroducing the
per-type clean_object_term_cache() call fails this assertion.
Refs #63773
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7N7bET4QnBktZqjgMq9ta
* chore(products): remove the stale ProductUtil PHPStan baseline entry
81e962776f pointed the delete_product_specific_transients() docblock at
\WC_Product. The file is namespaced and never imports the class, so the
bare WC_Product had been resolving to Internal\Utilities\WC_Product,
which doesn't exist — a class.notFound error baselined long before this
branch.
Naming the global class resolved that error but left its baseline entry
matching nothing. PHPStan runs with reportUnmatchedIgnoredErrors on, so
the analysis fails on the unmatched pattern rather than on any real
problem, which is why the PHPStan job is the one red check on the PR.
Delete the entry, which is what AGENTS.md asks of a change that resolves
a baselined error. No code change: only the tombstone was stale. A full
analyse run is clean afterwards.
Refs #63773
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJqcwBwY5W6t9ryJwZgo3D
* test(products): Simplify scheduled sales coverage
The scheduled-sales regressions repeated fixture setup while obscuring the behaviors that protect batching.
Reuse a focused fixture with stronger checks for hook payloads, cache priming, per-ID eviction, supported data-store shapes, malformed IDs, plus duplicate rows.
Refs #63773
* docs(products): Simplify scheduled sales comments
The batching implementation accumulated comments that restated nearby code, making its compatibility constraints harder to scan.
Keep only the memory, hook-timing, mixed-type, plus shared-cache rationale without changing executable PHP.
Refs #63773
* chore(products): Update scheduled sales changelog
The release note included hook-level details better suited to the PR description.
Summarize the merchant-facing memory improvement in one concise sentence.
Refs #63773
* style(products): Move scheduled sales helper below tests
The private fixture helper interrupted the public scheduled-sales test sequence.
Move it below the public test methods to keep the test flow together without changing behavior.
Refs #63773
* test(products): Assert hook cadence before reading payloads
The batching test read $before_payloads[0] before asserting the hook
fired once. When the hook never fires, that reads an undefined offset
and fails with a PHP notice instead of the assertion message.
Move the assertCount() calls above the payload reads.
Refs #66720
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017r9HgEueaf5v68BVVcdqob
* test(products): Cast term cache guard ids before strict comparison
Core fires clean_object_term_cache without casting $object_ids, and
some callers pass the raw string ids from $wpdb->get_col(). The guard
in the mixed-batch test compared them with in_array( ..., true ), so a
string id would slip past it silently.
Cast the ids to int before comparing.
Refs #66720
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017r9HgEueaf5v68BVVcdqob
* refactor(products): Extract scheduled sale batching into a class
wc_scheduled_sales() held the batching, cache release, and id
normalization in a static closure. That kept the logic out of the
container conventions used elsewhere in src/, and the only way to test
it was through the cron entry point with a replaced data store.
Move it to ScheduledSaleBatchProcessor, resolved from the container
next to ScheduledSalePriceReconciler, with process( $ids, $mode ) as
the entry point and the batch size as a class constant. The cron
function keeps loading the data store and firing the existing hooks
with the full list, so hook names, arguments, and cadence do not
change.
The batch-mechanics tests move to ScheduledSaleBatchProcessorTest and
call process() directly, which removes the anonymous data-store fakes.
The cron-contract tests stay with the function. A start-mode test is
added since that path had no coverage.
Refs #66720
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017r9HgEueaf5v68BVVcdqob
* fix(products): Drop the runtime cache copy per batch on external caches
Persistent object caches such as Redis Object Cache keep a request-local
copy of everything they serve. The batch release deletes posts, meta,
term relationships, and product objects by id, but the products and
term-queries groups cannot be addressed by id and were left alone on
external caches to keep the shared backend untouched. Their in-memory
copies therefore grew with the whole backlog: with Redis Object Cache
and 2,000 products the run retained about 14,000 runtime entries and
peaked 13 MB above its start, versus 3 MB with a periodic runtime
flush.
Call wp_cache_flush_runtime() after each batch when the shared groups
are not flushed. That only clears the in-memory copy, the same approach
the HPOS migration CLI uses.
Refs #66720
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017r9HgEueaf5v68BVVcdqob
* fix(products): Drop batch rows that are not whole positive ids
normalize_ids() promised to drop anything that is not a whole positive
number, but it cast object ID properties without checking them and let
is_numeric() through for fractions and exponent strings. A replaced
data store returning '123abc' as an object ID, or 123.9 as a scalar,
would resolve to post 123 and have its sale state and caches touched.
Accept only ints, integral floats, and digit-only strings. Cover the
malformed shapes with a data provider.
Refs #66720
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017r9HgEueaf5v68BVVcdqob
* test(products): Reduce scheduled sale test duplication
Scheduled sale batching had equivalent processor-level coverage plus integration-level coverage. The fixture setup was also repeated across both suites.
Keep the stronger integration check. Move the shared fixture to WC_Helper_Product.
Refs #63773
* refactor(products): Bind data to scheduled sale batches
Scheduled sale coordination and per-batch work currently live in one container-managed class, so transient batch state is passed between private methods.
Bind each 50-entry slice to a short-lived ScheduledSaleBatch while keeping dependency resolution and backlog coordination in the processor. Validate the mode at construction without changing hook payloads or batch boundaries.
* docs(products): Update scheduled sale classes to 11.3.0
* refactor(products): Simplify scheduled sale ID normalization
* fix(products): Validate scheduled sale mode before batching
Scheduled sale batches validate their mode in the batch constructor.
An empty product list never constructs a batch, so the processor silently accepted an unsupported mode.
Validate at the processor boundary while retaining constructor validation for direct batch use. Parameterize the contract test across empty plus non-empty lists.
Refs #63773
* fix(products): Normalize scheduled sale IDs across each run
Scheduled-sale data stores can return repeated rows across batch boundaries and supported product or post objects. Per-batch absint conversion can repeat saves or cast malformed rows onto unrelated IDs.
Process each result once in a run-level class while releasing 50-product batches. Preserve the raw hook payload and cover supported and malformed row shapes.
Refs #63773
* test(products): Remove redundant scheduled sale mode case
The run constructor rejects an invalid mode before inspecting product IDs, so empty and nonempty inputs exercise the same path.
Keep the empty-input case as a direct test and remove the single-case parameterization.
Refs #63773
* refactor(products): Name the scheduled sale modes as constants
The 'start' and 'end' strings were written out in ScheduledSaleRun's
constructor guard and again at both call sites in wc_scheduled_sales(),
so the vocabulary sat in three places with nothing linking them. A typo
at a call site only showed up as a thrown exception at runtime.
Add MODE_START and MODE_END alongside BATCH_SIZE and use them in the
guard and at both call sites. The string values are unchanged.
Refs #63773
* refactor(products): Extract scheduled sale ID normalization
ScheduledSaleRun processes data-store rows while tracking duplicates and filling batches. Inline entry conversion obscured the batch flow.
Extract the existing conversion rules into a private helper that returns null for invalid rows. This preserves accepted IDs and when batches are saved.
* refactor(products): Normalize scheduled sale IDs in constructor
ScheduledSaleRun receives raw product references from the data store. Keeping normalization in process() mixes input preparation with batch execution.
Build a unique, ordered ID map in the constructor using the existing validator, then process slices of 50. Hook payloads and accepted ID shapes stay unchanged.
* refactor(products): Initialize sale services in constructor
ScheduledSaleRun now normalizes product IDs during construction, but it still resolved processing services when process() began.
Resolve the product utility, optional cache, and cache capability during construction. Current callers process immediately, and the same cache conditions are set before construction.
---------
Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
Co-authored-by: Ahmed <ahmed.el.azzabi@automattic.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: oaratovskyi <oleksandr.aratovskyi@automattic.com>
diff --git a/plugins/woocommerce/changelog/fix-63773-scheduled-sales-batching b/plugins/woocommerce/changelog/fix-63773-scheduled-sales-batching
new file mode 100644
index 00000000000..474e569aa60
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-63773-scheduled-sales-batching
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Process scheduled sales in batches to reduce memory use and allow stores with large backlogs to make progress.
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index 6b31e0f2eec..a7f265bb19b 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Enums\CatalogVisibility;
use Automattic\WooCommerce\Enums\TaxDisplayMode;
use Automattic\WooCommerce\Internal\Caches\ProductTransientsDeferrer;
use Automattic\WooCommerce\Internal\ProductGallery\ProductMediaGallery;
+use Automattic\WooCommerce\Internal\ScheduledSaleRun;
use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
@@ -846,33 +847,24 @@ add_action( 'deleted_post_meta', 'wc_maybe_schedule_sale_events_on_meta_change',
* when this cron finds products to process. If per-product AS events handled sales
* on time, these hooks may not fire.
*
+ * Products are processed in batches by ScheduledSaleRun. Before hooks run
+ * before any batch is primed; after hooks run after the last batch's caches are cleared.
+ *
* @since 3.0.0
*/
function wc_scheduled_sales() {
$data_store = WC_Data_Store::load( 'product' );
- $product_util = wc_get_container()->get( ProductUtil::class );
$must_refresh_transient = false;
// Sales which are due to start.
$product_ids = $data_store->get_starting_sales();
if ( $product_ids ) {
- _prime_post_caches( $product_ids );
$must_refresh_transient = true;
do_action( 'wc_before_products_starting_sales', $product_ids );
- foreach ( $product_ids as $product_id ) {
- $product = wc_get_product( $product_id );
-
- if ( $product ) {
- wc_apply_sale_state_for_product( $product, 'start' );
- // Note: wc_apply_sale_state_for_product() calls save(), which writes sale
- // date meta and triggers wc_maybe_schedule_sale_events_on_meta_change(),
- // which schedules the end AS event.
- }
+ ( new ScheduledSaleRun( $product_ids, ScheduledSaleRun::MODE_START ) )->process();
- $product_util->delete_product_specific_transients( $product ? $product : $product_id );
- }
do_action( 'wc_after_products_starting_sales', $product_ids );
delete_transient( 'wc_products_onsale' );
}
@@ -880,19 +872,11 @@ function wc_scheduled_sales() {
// Sales which are due to end.
$product_ids = $data_store->get_ending_sales();
if ( $product_ids ) {
- _prime_post_caches( $product_ids );
$must_refresh_transient = true;
do_action( 'wc_before_products_ending_sales', $product_ids );
- foreach ( $product_ids as $product_id ) {
- $product = wc_get_product( $product_id );
+ ( new ScheduledSaleRun( $product_ids, ScheduledSaleRun::MODE_END ) )->process();
- if ( $product ) {
- wc_apply_sale_state_for_product( $product, 'end' );
- }
-
- $product_util->delete_product_specific_transients( $product ? $product : $product_id );
- }
do_action( 'wc_after_products_ending_sales', $product_ids );
delete_transient( 'wc_products_onsale' );
}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 5148e25d971..a3f80491f94 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -66727,12 +66727,6 @@ parameters:
count: 2
path: src/Internal/Utilities/PluginInstaller.php
- -
- message: '#^Parameter \$product_or_id of method Automattic\\WooCommerce\\Internal\\Utilities\\ProductUtil\:\:delete_product_specific_transients\(\) has invalid type Automattic\\WooCommerce\\Internal\\Utilities\\WC_Product\.$#'
- identifier: class.notFound
- count: 1
- path: src/Internal/Utilities/ProductUtil.php
-
-
message: '#^Cannot assign offset ''drive'' to array\<null\>\|string\.$#'
identifier: offsetAssign.dimType
diff --git a/plugins/woocommerce/src/Internal/ScheduledSaleRun.php b/plugins/woocommerce/src/Internal/ScheduledSaleRun.php
new file mode 100644
index 00000000000..5cb8725a7a2
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ScheduledSaleRun.php
@@ -0,0 +1,212 @@
+<?php
+/**
+ * ScheduledSaleRun class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal;
+
+use Automattic\WooCommerce\Internal\Caches\ProductCache;
+use Automattic\WooCommerce\Internal\Caches\ProductCacheController;
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+use WC_Product;
+
+/**
+ * Starts or ends scheduled sales, processing and releasing one batch at a time.
+ *
+ * @internal Just for internal use.
+ * @since 11.3.0
+ */
+class ScheduledSaleRun {
+
+ /**
+ * How many products are loaded and processed at a time.
+ *
+ * @var int
+ */
+ public const BATCH_SIZE = 50;
+
+ /**
+ * Mode for a sale that is starting.
+ *
+ * @var string
+ */
+ public const MODE_START = 'start';
+
+ /**
+ * Mode for a sale that is ending.
+ *
+ * @var string
+ */
+ public const MODE_END = 'end';
+
+ /**
+ * Unique product IDs in data-store order.
+ *
+ * @var int[]
+ */
+ private array $product_ids;
+
+ /**
+ * The sale mode.
+ *
+ * @var string
+ */
+ private string $mode;
+
+ /**
+ * The product utility.
+ *
+ * @var ProductUtil
+ */
+ private ProductUtil $product_util;
+
+ /**
+ * The product object cache, when the feature is enabled.
+ *
+ * @var ProductCache|null
+ */
+ private ?ProductCache $product_cache;
+
+ /**
+ * Whether the products and term-queries groups may be flushed.
+ *
+ * @var bool
+ */
+ private bool $flush_shared_groups;
+
+ /**
+ * Initialize a scheduled sale run.
+ *
+ * @param mixed[] $entries Product references returned by the data store.
+ * @param string $mode One of MODE_START or MODE_END.
+ * @throws \InvalidArgumentException When the sale mode is unsupported.
+ */
+ public function __construct( array $entries, string $mode ) {
+ if ( ! in_array( $mode, array( self::MODE_START, self::MODE_END ), true ) ) {
+ throw new \InvalidArgumentException( 'Scheduled sale mode must be either start or end.' );
+ }
+
+ $this->mode = $mode;
+ $this->product_ids = array();
+
+ foreach ( $entries as $entry ) {
+ $product_id = $this->normalize_entry( $entry );
+ if ( null !== $product_id ) {
+ $this->product_ids[ $product_id ] = $product_id;
+ }
+ }
+
+ $this->product_util = wc_get_container()->get( ProductUtil::class );
+ $this->product_cache = FeaturesUtil::feature_is_enabled( ProductCacheController::FEATURE_NAME )
+ ? wc_get_container()->get( ProductCache::class )
+ : null;
+
+ // Shared groups can only be flushed when the cache belongs to this request.
+ $this->flush_shared_groups = wp_cache_supports( 'flush_group' ) && ! wp_using_ext_object_cache();
+ }
+
+ /**
+ * Process normalized product IDs in batches.
+ */
+ public function process(): void {
+ $total = count( $this->product_ids );
+ for ( $offset = 0; $offset < $total; $offset += self::BATCH_SIZE ) {
+ $this->process_batch( array_slice( $this->product_ids, $offset, self::BATCH_SIZE ) );
+ }
+ }
+
+ /**
+ * Normalize a data-store row to a positive product ID.
+ *
+ * @param mixed $entry Product reference returned by the data store.
+ * @return int|null Positive product ID, or null for an invalid row.
+ */
+ private function normalize_entry( $entry ): ?int {
+ if ( $entry instanceof WC_Product ) {
+ $entry = $entry->get_id();
+ } elseif ( is_object( $entry ) ) {
+ $entry = $entry->ID ?? null;
+ }
+
+ if ( is_int( $entry ) ) {
+ $product_id = $entry;
+ } elseif ( is_float( $entry ) && is_finite( $entry ) && floor( $entry ) === $entry ) {
+ $product_id = (int) $entry;
+ } elseif ( is_string( $entry ) && ctype_digit( $entry ) ) {
+ $product_id = (int) $entry;
+ } else {
+ return null;
+ }
+
+ return $product_id > 0 ? $product_id : null;
+ }
+
+ /**
+ * Prime, process, and release one batch.
+ *
+ * @param int[] $batch_ids Product ids in this batch.
+ */
+ private function process_batch( array $batch_ids ): void {
+ _prime_post_caches( $batch_ids );
+
+ // Capture post types before product saves evict the primed posts.
+ $post_types = array_values( array_unique( array_filter( array_map( 'get_post_type', $batch_ids ) ) ) );
+
+ foreach ( $batch_ids as $product_id ) {
+ $product = wc_get_product( $product_id );
+
+ if ( $product ) {
+ // Only the price changes, so this does not reschedule the sale event.
+ wc_apply_sale_state_for_product( $product, $this->mode );
+ }
+
+ $this->product_util->delete_product_specific_transients( $product ? $product : $product_id );
+ }
+
+ $this->release_caches( $batch_ids, $post_types );
+ }
+
+ /**
+ * Release the object cache entries this batch left behind.
+ *
+ * Only this batch's entries are deleted. clean_post_cache() would invalidate wider
+ * cache state that the batch did not populate, and clean_object_term_cache() accepts
+ * a single object type while a batch can hold both products and variations.
+ *
+ * @param int[] $batch_ids Product ids in this batch.
+ * @param string[] $post_types Post types found in this batch.
+ */
+ private function release_caches( array $batch_ids, array $post_types ): void {
+ wp_cache_delete_multiple( $batch_ids, 'posts' );
+ wp_cache_delete_multiple( $batch_ids, 'post_meta' );
+
+ $taxonomies = array();
+ foreach ( $post_types as $post_type ) {
+ $taxonomies = array_merge( $taxonomies, get_object_taxonomies( $post_type ) );
+ }
+
+ foreach ( array_unique( $taxonomies ) as $taxonomy ) {
+ wp_cache_delete_multiple( $batch_ids, "{$taxonomy}_relationships" );
+ }
+
+ // Remove entries reloaded while clearing product transients.
+ if ( $this->product_cache ) {
+ foreach ( $batch_ids as $product_id ) {
+ $this->product_cache->remove( $product_id );
+ }
+ }
+
+ if ( $this->flush_shared_groups ) {
+ wp_cache_flush_group( 'products' );
+ wp_cache_flush_group( 'term-queries' );
+ } elseif ( wp_cache_supports( 'flush_runtime' ) ) {
+ // A persistent cache keeps a request-local copy of everything it serves, so the
+ // entries above that cannot be deleted by id would accumulate for the whole
+ // backlog. Drop the in-memory copy only; the shared backend is untouched.
+ wp_cache_flush_runtime();
+ }
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
index e0f5547de88..b90e79d744a 100644
--- a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
@@ -72,7 +72,7 @@ class ProductUtil {
* Delete the transients related to a specific product.
* If the product is a variation, delete the transients for the parent too.
*
- * @param WC_Product|int $product_or_id The product or the product id.
+ * @param \WC_Product|int $product_or_id The product or the product id.
* @return void
*/
public function delete_product_specific_transients( $product_or_id ) {
diff --git a/plugins/woocommerce/tests/legacy/framework/helpers/class-wc-helper-product.php b/plugins/woocommerce/tests/legacy/framework/helpers/class-wc-helper-product.php
index 847d974435f..2d6cba22550 100644
--- a/plugins/woocommerce/tests/legacy/framework/helpers/class-wc-helper-product.php
+++ b/plugins/woocommerce/tests/legacy/framework/helpers/class-wc-helper-product.php
@@ -71,6 +71,24 @@ class WC_Helper_Product {
}
}
+ /**
+ * Create a product whose sale has ended while its stored price is still the sale price.
+ *
+ * @return WC_Product
+ */
+ public static function create_missed_sale_end_product() {
+ $product = self::create_simple_product();
+ $product->set_regular_price( '100' );
+ $product->set_sale_price( '50' );
+ $product->save();
+
+ update_post_meta( $product->get_id(), '_price', 50 );
+ update_post_meta( $product->get_id(), '_sale_price_dates_from', time() - 300 );
+ update_post_meta( $product->get_id(), '_sale_price_dates_to', time() - 100 );
+
+ return $product;
+ }
+
/**
* Create a downloadable product.
*
diff --git a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
index eccca204e85..34c5af756b9 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -2750,4 +2750,107 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
delete_option( 'woocommerce_product_match_featured_image_by_sku' );
}
}
+
+ /**
+ * @testdox Every product is processed when the backlog spans more than one batch.
+ */
+ public function test_wc_scheduled_sales_processes_every_product_across_batches(): void {
+ $ids = array();
+ for ( $i = 0; $i < 51; $i++ ) {
+ $product = WC_Helper_Product::create_missed_sale_end_product();
+ $ids[] = $product->get_id();
+ }
+
+ $before_payloads = array();
+ $after_payloads = array();
+ add_action(
+ 'wc_before_products_ending_sales',
+ function ( $hook_ids ) use ( &$before_payloads ) {
+ $before_payloads[] = $hook_ids;
+ }
+ );
+ add_action(
+ 'wc_after_products_ending_sales',
+ function ( $hook_ids ) use ( &$after_payloads ) {
+ $after_payloads[] = $hook_ids;
+ }
+ );
+
+ wc_scheduled_sales();
+
+ foreach ( $ids as $id ) {
+ $this->assertEquals(
+ 100,
+ get_post_meta( $id, '_price', true ),
+ "Product {$id} was not processed, so a batch boundary dropped it."
+ );
+ }
+
+ $this->assertCount( 1, $before_payloads, 'The before hook must fire once, not once per batch.' );
+ $this->assertCount( 1, $after_payloads, 'The after hook must fire once, not once per batch.' );
+
+ $expected_ids = array_map( 'intval', $ids );
+ $before_ids = array_map( 'intval', $before_payloads[0] );
+ $after_ids = array_map( 'intval', $after_payloads[0] );
+
+ $this->assertEqualsCanonicalizing( $expected_ids, $before_ids, 'The before hook must receive the whole backlog.' );
+ $this->assertEqualsCanonicalizing( $expected_ids, $after_ids, 'The after hook must receive the whole backlog.' );
+ $this->assertSame( $before_payloads[0], $after_payloads[0], 'The hook payload must not change while batches are processed.' );
+ }
+
+ /**
+ * @testdox Priming happens per batch, not once for the whole backlog.
+ */
+ public function test_wc_scheduled_sales_primes_one_batch_at_a_time(): void {
+ $ids = array();
+
+ for ( $i = 0; $i < 51; $i++ ) {
+ $product = WC_Helper_Product::create_missed_sale_end_product();
+ $ids[] = $product->get_id();
+ }
+
+ $queued = array();
+ add_action(
+ 'wc_before_products_ending_sales',
+ static function ( $product_ids ) use ( &$queued ) {
+ $queued = $product_ids;
+ }
+ );
+
+ $cache_state_during_first_save = null;
+ add_action(
+ 'woocommerce_update_product',
+ static function () use ( &$queued, &$cache_state_during_first_save ) {
+ if ( null !== $cache_state_during_first_save || count( $queued ) < 51 ) {
+ return;
+ }
+
+ $cache_state_during_first_save = array(
+ 'first_batch_peer' => false !== wp_cache_get( (int) $queued[1], 'posts' ),
+ 'first_batch_peer_meta' => false !== wp_cache_get( (int) $queued[1], 'post_meta' ),
+ 'next_batch' => false !== wp_cache_get( (int) end( $queued ), 'posts' ),
+ );
+ }
+ );
+
+ // Fixture creation warms the cache; emulate a fresh cron request.
+ wp_cache_flush();
+
+ wc_scheduled_sales();
+
+ $this->assertCount( 51, $queued, 'Fixture precondition: the backlog must exceed one batch.' );
+ $this->assertIsArray( $cache_state_during_first_save, 'The cache state should be captured during the first save.' );
+ $this->assertTrue(
+ $cache_state_during_first_save['first_batch_peer'],
+ 'A product in the first batch should already be primed during the first save.'
+ );
+ $this->assertTrue(
+ $cache_state_during_first_save['first_batch_peer_meta'],
+ 'A product meta in the first batch should already be primed during the first save.'
+ );
+ $this->assertFalse(
+ $cache_state_during_first_save['next_batch'],
+ 'The next batch must not be primed while the first batch is processing.'
+ );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ScheduledSaleRunTest.php b/plugins/woocommerce/tests/php/src/Internal/ScheduledSaleRunTest.php
new file mode 100644
index 00000000000..8a9de5fa5ee
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ScheduledSaleRunTest.php
@@ -0,0 +1,374 @@
+<?php
+/**
+ * ScheduledSaleRunTest class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal;
+
+use Automattic\WooCommerce\Internal\Caches\ProductCache;
+use Automattic\WooCommerce\Internal\Caches\ProductCacheController;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\ScheduledSaleRun;
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+use WC_Helper_Product;
+use WC_Product_Variable;
+use WC_Product_Variation;
+use WC_Unit_Test_Case;
+use WP_Object_Cache;
+
+/**
+ * Tests for the ScheduledSaleRun class.
+ */
+class ScheduledSaleRunTest extends WC_Unit_Test_Case {
+
+ /**
+ * Process a complete scheduled-sale run.
+ *
+ * @param array $rows Data-store rows.
+ * @param string $mode Sale mode.
+ */
+ private function process_run( array $rows, string $mode ): void {
+ ( new ScheduledSaleRun( $rows, $mode ) )->process();
+ }
+
+ /**
+ * @testdox Starting a sale stores the sale price as the active price.
+ */
+ public function test_start_mode_applies_the_sale_price(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 100 );
+ $product->set_sale_price( 50 );
+ $product->set_date_on_sale_from( time() - 100 );
+ $product->set_date_on_sale_to( time() + 300 );
+ $product->save();
+ update_post_meta( $product->get_id(), '_price', 100 );
+
+ $this->process_run( array( $product->get_id() ), 'start' );
+
+ $this->assertEquals( 50, get_post_meta( $product->get_id(), '_price', true ), 'The sale price should be the active price once the sale starts.' );
+ }
+
+ /**
+ * @testdox An unsupported sale mode is rejected without product ids.
+ */
+ public function test_rejects_an_unsupported_mode(): void {
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'Scheduled sale mode must be either start or end.' );
+
+ $this->process_run( array(), 'invalid' );
+ }
+
+ /**
+ * @testdox An external object cache has only its in-memory copy dropped, never a shared group.
+ */
+ public function test_drops_only_the_runtime_copy_on_an_external_cache(): void {
+ $product = WC_Helper_Product::create_missed_sale_end_product();
+
+ $spy = new class() extends WP_Object_Cache {
+ /**
+ * Groups passed to flush_group().
+ *
+ * @var string[]
+ */
+ public $flushed_groups = array();
+
+ /**
+ * How many times the whole in-memory cache was flushed.
+ *
+ * The default cache implements wp_cache_flush_runtime() as a full flush.
+ *
+ * @var int
+ */
+ public $runtime_flushes = 0;
+
+ /**
+ * Record the group instead of flushing it.
+ *
+ * @param string $group Group name.
+ * @return bool
+ */
+ public function flush_group( $group ) {
+ $this->flushed_groups[] = $group;
+ return true;
+ }
+
+ /**
+ * Count the call, then flush.
+ *
+ * @return bool
+ */
+ public function flush() {
+ ++$this->runtime_flushes;
+ return parent::flush();
+ }
+ };
+
+ $real_cache = $GLOBALS['wp_object_cache'];
+ // Cast null to false because passing null reads rather than restores the flag.
+ $was_external = (bool) wp_using_ext_object_cache( true );
+
+ try {
+ $GLOBALS['wp_object_cache'] = $spy; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Swapped back in finally.
+ $this->process_run( array( $product->get_id() ), 'end' );
+ } finally {
+ $GLOBALS['wp_object_cache'] = $real_cache; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ wp_using_ext_object_cache( $was_external );
+ }
+
+ // The real cache still holds the meta primed before the swap.
+ wp_cache_delete( $product->get_id(), 'post_meta' );
+
+ $this->assertSame( array(), $spy->flushed_groups, 'No shared group may be flushed when an external object cache is in use.' );
+ $this->assertSame( 1, $spy->runtime_flushes, 'The in-memory copy should be dropped once per batch.' );
+ $this->assertEquals( 100, get_post_meta( $product->get_id(), '_price', true ), 'The product must still settle on an external cache.' );
+ }
+
+ /**
+ * @testdox A request-local cache has the flushed groups released after the run.
+ */
+ public function test_releases_the_flushed_groups_on_a_request_local_cache(): void {
+ if ( ! wp_cache_supports( 'flush_group' ) || wp_using_ext_object_cache() ) {
+ $this->markTestSkipped( 'Requires a request-local object cache with flush_group support.' );
+ }
+
+ $product = WC_Helper_Product::create_missed_sale_end_product();
+
+ wp_cache_set( 'sentinel', 'release me', 'products' );
+ wp_cache_set( 'sentinel', 'release me', 'term-queries' );
+
+ $this->process_run( array( $product->get_id() ), 'end' );
+
+ $this->assertFalse( wp_cache_get( 'sentinel', 'products' ), 'The products group must be flushed when the cache is request-local.' );
+ $this->assertFalse( wp_cache_get( 'sentinel', 'term-queries' ), 'The term-queries group must be flushed when the cache is request-local.' );
+ $this->assertEquals( 100, get_post_meta( $product->get_id(), '_price', true ), 'Fixture precondition: the product should have been processed.' );
+ }
+
+ /**
+ * @testdox product_objects entries are released by id, independent of flush_group support.
+ */
+ public function test_releases_the_product_objects_cache_by_id(): void {
+ $features_controller = wc_get_container()->get( FeaturesController::class );
+ $was_enabled = FeaturesUtil::feature_is_enabled( ProductCacheController::FEATURE_NAME );
+ $features_controller->change_feature_enable( ProductCacheController::FEATURE_NAME, true );
+
+ try {
+ $product = WC_Helper_Product::create_missed_sale_end_product();
+
+ $product_cache = wc_get_container()->get( ProductCache::class );
+ $unrelated_product = WC_Helper_Product::create_simple_product();
+ $product_cache->set( $product );
+ $product_cache->set( $unrelated_product );
+ $this->assertTrue( $product_cache->is_cached( $product->get_id() ), 'Fixture precondition: the product must be cached before the run.' );
+
+ $this->process_run( array( $product->get_id() ), 'end' );
+
+ $this->assertFalse( $product_cache->is_cached( $product->get_id() ), 'The processed product must be released from product_objects.' );
+ $this->assertTrue( $product_cache->is_cached( $unrelated_product->get_id() ), 'Per-ID cleanup must leave unrelated product_objects entries intact.' );
+ } finally {
+ $features_controller->change_feature_enable( ProductCacheController::FEATURE_NAME, $was_enabled );
+ }
+ }
+
+ /**
+ * @testdox A batch holding both a product and a variation leaves no term relationship cached.
+ */
+ public function test_releases_term_caches_for_a_mixed_batch(): void {
+ // Priming uses the union of the product and variation taxonomies for every batch ID.
+ $parent = new WC_Product_Variable();
+ $parent->set_name( 'Mixed batch parent' );
+ $parent->set_regular_price( 100 );
+ $parent->set_sale_price( 50 );
+ $parent->save();
+
+ $variation = new WC_Product_Variation();
+ $variation->set_parent_id( $parent->get_id() );
+ $variation->set_regular_price( 100 );
+ $variation->set_sale_price( 50 );
+ $variation->save();
+
+ $ids = array( $parent->get_id(), $variation->get_id() );
+
+ foreach ( $ids as $id ) {
+ update_post_meta( $id, '_price', 50 );
+ update_post_meta( $id, '_sale_price_dates_from', time() - 300 );
+ update_post_meta( $id, '_sale_price_dates_to', time() - 100 );
+ }
+
+ $taxonomies = array_unique(
+ array_merge( get_object_taxonomies( 'product' ), get_object_taxonomies( 'product_variation' ) )
+ );
+
+ wp_cache_flush();
+ _prime_post_caches( $ids );
+ $this->assertNotFalse( wp_cache_get( $variation->get_id(), 'product_cat_relationships' ), 'Fixture precondition: priming must cache the product-only groups for the variation.' );
+
+ // Guard against firing clean_object_term_cache with mismatched object types.
+ $fired_with_wrong_type = false;
+ add_action(
+ 'clean_object_term_cache',
+ function ( $object_ids, $object_type ) use ( $parent, $variation, &$fired_with_wrong_type ) {
+ $object_ids = array_map( 'intval', (array) $object_ids );
+
+ if ( in_array( $parent->get_id(), $object_ids, true ) && 'product' !== $object_type ) {
+ $fired_with_wrong_type = true;
+ }
+
+ if ( in_array( $variation->get_id(), $object_ids, true ) && 'product_variation' !== $object_type ) {
+ $fired_with_wrong_type = true;
+ }
+ },
+ 10,
+ 2
+ );
+
+ $this->process_run( $ids, 'end' );
+
+ $this->assertFalse( $fired_with_wrong_type, 'clean_object_term_cache must not fire with a post type that does not match the ids it was given.' );
+
+ foreach ( $ids as $id ) {
+ foreach ( $taxonomies as $taxonomy ) {
+ $this->assertFalse( wp_cache_get( $id, "{$taxonomy}_relationships" ), "Post {$id} should hold no {$taxonomy} relationship cache after the run." );
+ }
+ }
+ }
+
+ /**
+ * @testdox Releasing a batch does not evict cache entries belonging to other posts.
+ */
+ public function test_leaves_unrelated_post_caches_intact(): void {
+ $page_id = self::factory()->post->create( array( 'post_type' => 'page' ) );
+ update_post_meta( $page_id, '_unrelated', 'keep me' );
+ get_post( $page_id );
+ get_post_meta( $page_id );
+ $this->assertNotFalse( wp_cache_get( $page_id, 'posts' ), 'Fixture precondition: the page should be primed before the run.' );
+ $this->assertNotFalse( wp_cache_get( $page_id, 'post_meta' ), 'Fixture precondition: the page meta should be primed before the run.' );
+
+ $product = WC_Helper_Product::create_missed_sale_end_product();
+
+ $this->process_run( array( $product->get_id() ), 'end' );
+
+ // Assert before get_post_meta() can repopulate the released cache.
+ $this->assertFalse( wp_cache_get( $product->get_id(), 'posts' ), 'The batch did not release its own post cache entry.' );
+ $this->assertFalse( wp_cache_get( $product->get_id(), 'post_meta' ), 'The batch did not release its own post meta cache entry.' );
+
+ $this->assertEquals( 100, get_post_meta( $product->get_id(), '_price', true ), 'Fixture precondition: the product should have been processed.' );
+ $this->assertNotFalse( wp_cache_get( $page_id, 'posts' ), 'The run released an unrelated post from the shared posts cache.' );
+ $this->assertNotFalse( wp_cache_get( $page_id, 'post_meta' ), 'The run released unrelated meta from the shared post_meta cache.' );
+ }
+
+ /**
+ * @testdox Supported data-store result shapes still settle.
+ */
+ public function test_settles_supported_data_store_result_shapes(): void {
+ $ids = array();
+
+ for ( $i = 0; $i < 3; $i++ ) {
+ $ids[] = WC_Helper_Product::create_missed_sale_end_product()->get_id();
+ }
+
+ $rows = array(
+ (string) $ids[0],
+ wc_get_product( $ids[1] ),
+ get_post( $ids[2] ),
+ );
+
+ $this->process_run( $rows, 'end' );
+
+ foreach ( $ids as $index => $id ) {
+ $this->assertEquals(
+ 100,
+ get_post_meta( $id, '_price', true ),
+ "Product {$id}, supplied as " . wp_json_encode( $rows[ $index ] ) . ', should have settled.'
+ );
+ }
+ }
+
+ /**
+ * Malformed data-store rows that could cast onto an unrelated post.
+ *
+ * @return array
+ */
+ public function provider_malformed_rows(): array {
+ return array(
+ 'string with trailing junk' => array( fn( int $id ) => $id . 'abc' ),
+ 'fractional float' => array( fn( int $id ) => $id + 0.9 ),
+ 'fractional string' => array( fn( int $id ) => $id . '.9' ),
+ 'object with junk ID' => array( fn( int $id ) => (object) array( 'ID' => $id . 'abc' ) ),
+ 'object with fractional ID' => array( fn( int $id ) => (object) array( 'ID' => $id + 0.9 ) ),
+ 'object without ID' => array( fn( int $id ) => (object) array( 'id' => $id ) ),
+ 'negative int' => array( fn( int $id ) => -$id ),
+ 'exponent string' => array( fn( int $id ) => $id . 'e0' ),
+ );
+ }
+
+ /**
+ * @testdox A malformed row does not stop the run or evict an unrelated post.
+ * @dataProvider provider_malformed_rows
+ *
+ * @param callable $make_row Builds the malformed row from the decoy post id.
+ */
+ public function test_drops_malformed_rows( callable $make_row ): void {
+ $product = WC_Helper_Product::create_missed_sale_end_product();
+ $decoy_id = self::factory()->post->create( array( 'post_type' => 'page' ) );
+ $row = $make_row( $decoy_id );
+ get_post( $decoy_id );
+ $this->assertNotFalse( wp_cache_get( $decoy_id, 'posts' ), 'Fixture precondition: the decoy page should be primed before the run.' );
+
+ $this->process_run( array( $product->get_id(), $row ), 'end' );
+
+ $this->assertEquals( 100, get_post_meta( $product->get_id(), '_price', true ), 'A malformed row stopped the run before the real product settled.' );
+ $this->assertNotFalse( wp_cache_get( $decoy_id, 'posts' ), 'The release cast ' . wp_json_encode( $row ) . " onto post {$decoy_id} and evicted an unrelated page." );
+ }
+
+ /**
+ * @testdox An id listed twice in one batch is processed once.
+ */
+ public function test_processes_a_duplicated_id_once(): void {
+ // Duplicate postmeta rows can return the same product more than once.
+ $product = WC_Helper_Product::create_missed_sale_end_product();
+
+ $saves = 0;
+ add_action(
+ 'woocommerce_update_product',
+ static function ( $updated_id ) use ( $product, &$saves ) {
+ if ( (int) $updated_id === $product->get_id() ) {
+ ++$saves;
+ }
+ }
+ );
+
+ $this->process_run( array( $product->get_id(), (string) $product->get_id() ), 'end' );
+
+ $this->assertSame( 1, $saves, 'A duplicated id must settle with a single save, not one per row.' );
+ $this->assertEquals( 100, get_post_meta( $product->get_id(), '_price', true ), 'Fixture precondition: the product should have been processed.' );
+ }
+
+ /**
+ * @testdox An id repeated across batch boundaries is saved once.
+ */
+ public function test_processes_a_duplicated_id_across_batches_once(): void {
+ $product = WC_Helper_Product::create_missed_sale_end_product();
+ $rows = array( $product->get_id() );
+ for ( $i = 1; $i < ScheduledSaleRun::BATCH_SIZE; $i++ ) {
+ $rows[] = WC_Helper_Product::create_missed_sale_end_product()->get_id();
+ }
+ $rows[] = (string) $product->get_id();
+
+ $saves = 0;
+ add_action(
+ 'woocommerce_update_product',
+ static function ( $updated_id ) use ( $product, &$saves ) {
+ if ( (int) $updated_id === $product->get_id() ) {
+ ++$saves;
+ }
+ }
+ );
+
+ $this->process_run( $rows, 'end' );
+
+ $this->assertSame( 1, $saves, 'An id repeated after a batch boundary must not be saved twice.' );
+ $this->assertEquals( 100, get_post_meta( $product->get_id(), '_price', true ), 'The product should still settle.' );
+ }
+}