Commit e705ce8e75e for woocommerce
commit e705ce8e75e5c7eae7ca5de566dcf55a8f997586
Author: malinajirka <malinajirka@gmail.com>
Date: Fri Jul 3 10:04:11 2026 +0200
Lock POS catalog feed file to prevent concurrent-write corruption (#65924)
* Detect and recover stuck POS catalog feed generation jobs
When a large catalog feed is generated, the whole walk runs inside a single
Action Scheduler action. If the server kills that process (request/PHP timeout
or out of memory) before it finishes, the catch block never runs, so the status
option stays pinned at `in_progress` forever. Nothing reconciles it:
validate_status() only had a staleness check for the `scheduled` state, and
force_regeneration() refused to touch an `in_progress` job. The catalog could
then never be regenerated.
Add a heartbeat (`updated_at`) that is refreshed when a job is scheduled, when
it transitions to `in_progress`, and after every processed batch. validate_status()
now treats an `in_progress` job whose heartbeat is older than a filterable timeout
(woocommerce_product_feed_in_progress_timeout, default 5 minutes) as invalid, so
both status polling and force_regeneration() automatically reschedule a fresh job.
A genuinely running job keeps its heartbeat fresh and is left untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Extend per-batch PHP time limit during POS catalog feed generation
ProductWalker already supported resetting PHP's execution time limit per batch
via add_time_limit(), but AsyncGenerator never called it, so generation relied
on whatever max_execution_time the host applied to the Action Scheduler request
(often as low as 30s). Reset the time limit after each batch via the new
woocommerce_product_feed_batch_time_limit filter (default 5 minutes) so a low
max_execution_time does not abort a large catalog mid-walk.
This is a mitigation, not a guarantee: it does not extend Action Scheduler's
300s failure period or a hard host request timeout. The heartbeat-based recovery
remains the safety net when a job is killed regardless of the cause.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Raise memory and execution time limits during POS catalog feed generation
The previous change reset PHP's execution time limit after each batch, but the
walker only does so once a batch completes, so the initial product query and the
first batch still ran under whatever (often low) limit the Action Scheduler
request started with. Raise the time limit up front as well, and raise the memory
limit before the walk begins, since large catalogs are memory heavy and out of
memory is a likely cause of the process being killed.
These raises are scoped to the generating process and never lower an existing
higher limit. They cannot override a hard host/server request timeout or Action
Scheduler's failure period, which remain the ceilings that only chunked
processing can remove.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Generate POS catalog feed in chunks across multiple actions
Previously the entire catalog feed was built inside a single Action Scheduler
action. On large catalogs that one run exceeded the host request timeout / out
of memory or Action Scheduler's 300s failure period, killing the job.
Process the feed in bounded chunks instead: each action walks up to
woocommerce_product_feed_chunk_size products (default 2500), persists its
progress, and schedules + dispatches the next chunk, so no single run is long
enough to be killed. The feed file is written across processes via new
FeedInterface lifecycle methods (begin/resume/flush/finalize) into the shared
upload directory, with the cursor (file name, page, entries written) tracked in
the status option. ProductWalker gains walk_chunk() for bounded, resumable walks.
New filters: woocommerce_product_feed_chunk_size (products per chunk) and
woocommerce_product_feed_batch_size (products per DB query). Internal cursor
fields are stripped from the REST response.
The heartbeat-based recovery from the previous change still applies: if a chunk
is killed, the stale heartbeat causes generation to restart cleanly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Simplify POS catalog feed lifecycle and adapt chunk size on stuck
Collapse the feed storage API: fold begin/can_resume/resume into start()
and finalize() into end(), merge ProductWalker::walk_chunk() back into
walk(), and drop the now-dead temp-dir path. Also fix entries_written
being double-counted across chunks (it is already cumulative).
Make the chunk size adaptive instead of fixed: generation now starts at a
large size so most catalogs finish in a single action (no inter-action
latency), and only steps the size down a ladder (100000 -> 2500 -> 1000)
when a run is detected stuck. The effective size is persisted per feed so
the next request from the app does not repeat the attempt that failed.
Stop resuming stuck jobs: a stale in-progress job is now discarded and
restarted fresh, which avoids appending to a partial file left by a
killed run. Feed-generation logs now include the chunk size.
* Drop resume-after-kill fallback from POS catalog feed generation
On a stuck job, generation already restarts from scratch (the partial is
discarded and a fresh feed begins). The defensive machinery to resume a
killed run mid-file was therefore only stitching together successful
chunks. Remove it: a continuation whose partial file has vanished now
fails (and recovers on the next poll) instead of silently falling back.
Also trim comments to business rationale only and simplify the feed
lifecycle (no reopen-after-flush in end(), single is_file guard on
resume).
* Consolidate and correct POS catalog feed changelog entries
Merge the two POS catalog changelog entries into one and drop the stale
claim that a stalled run resumes from the last completed chunk; it now
restarts from scratch.
* Add changefile(s) from automation for the following project(s): woocommerce
* Fix PHPStan errors in POS catalog feed walker and storage
Drop the dead null-coalesce fallback on the walker's return value (the
do-while always assigns $progress on its first iteration) and have
open_handle() return the validated resource so the feed's initial
fwrite() operates on a definite resource rather than resource|false|null.
* Regenerate POS catalog feed on failed status instead of stalling
A failed generation job (e.g. a continuation whose partial feed file
vanished) was a terminal state: validate_status() reported it as valid,
so a status poll returned `failed` unchanged and only an explicit
force_regeneration() could restart it. That pushed an implementation
detail onto the client.
Treat `failed` as invalid in validate_status() so an ordinary status
poll discards the partial feed and starts a fresh generation, the same
way stuck and expired jobs are already handled. The now-unreachable
`failed` case in force_regeneration() is removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Strip internal updated_at heartbeat from feed status API response
The generate_feed endpoint already removes internal fields (action_id,
path, file_name, page, entries_written), but the new updated_at
heartbeat leaked through as a raw Unix timestamp. Add it to the unset
list and extend ApiControllerTest to assert every internal field is
omitted from the response.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Consolidate POS catalog chunked-generation changelog into one entry
There were two changelog files for the same feature: a real entry and a
Comment-only duplicate. Keep a single entry and fold the fuller wording
(chunk-size reduction, raised execution/memory limits) into it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Fix JsonFileFeed alignment warning and dead file_handle type
Align the $handle assignment to satisfy phpcs, and drop `false` from the
$file_handle property type: open_handle() now throws on a failed fopen()
rather than storing false, so the type is only ever resource|null.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Add changefile(s) from automation for the following project(s): woocommerce
* Harden POS catalog feed deletion against path traversal
Feed identifiers and paths round-trip through the persisted status option
and FeedInterface::delete() is callable by extensions, so a corrupted or
hostile value must never escape the feed directory.
- JsonFileFeed: validate that a feed identifier is a plain `.json` file
name before concatenating it into a path. start() throws on a bad
resume identifier; delete() ignores one rather than removing a file
elsewhere.
- AsyncGenerator: discard_feed() now prefers the feed-owned file_name
(validated by delete()) and only falls back to a raw status path after
confirming it resolves inside the feed upload directory. The completed
case of force_regeneration() routes through discard_feed() too.
Addresses CodeRabbit review on PR #65860.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Lock POS catalog feed file while writing to prevent corruption
* Add changelog entry for POS catalog feed file lock
* Simplify discard_feed() by deleting legacy feeds via their basename
* Surface failed POS catalog generation instead of silently retrying
* Widen POS catalog stuck timeout beyond the batch budget to avoid false recovery
* Fix lock contention marking a healthy POS catalog feed job as failed
* Stub create_feed() in discard_feed tests so deletion actually runs
The two discard_feed() tests mocked POSIntegration but never stubbed
create_feed(), which returns a FeedInterface. PHPUnit therefore auto-
returned a test double whose delete() is a silent no-op, so discard_feed()
never deleted anything:
- test_discard_feed_deletes_legacy_path_inside_feed_dir failed because the
real partial feed it created was never removed (assertFalse(file_exists)
saw the file still there).
- test_discard_feed_does_not_delete_path_outside_feed_dir passed only
trivially, since the no-op delete() can't remove the sentinel regardless,
so it never actually exercised the feed-directory confinement.
Stub create_feed() with a real JsonFileFeed (as the other tests already do)
so delete() runs for real: the inside-dir case now deletes the file and the
outside-dir case is confined to the feed directory and leaves the sentinel
untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Step POS catalog chunk size down on a forced rebuild of a stuck job too
force_regeneration() discarded and deleted a stuck in-progress job's status
before delegating to get_status(), so get_status() never saw the in-progress
state and never reduced the chunk size. A forced rebuild of a job that was
killed for being too large therefore restarted at the same size and was prone
to dying the same way, while an ordinary force=false poll would have stepped it
down.
Extract the "reduce when the invalid status was a stuck in-progress job" rule
into reduce_chunk_size_if_stuck() and call it from both get_status() and
force_regeneration(), so a stuck job adapts its chunk size the same way however
it is recovered. A genuine failure (state = failed) stays excluded — it is a
real error, not a size symptom. Extend the force_regeneration stalled-job test
to assert the step-down.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Document the 11.0.0 addition of FeedInterface::start() parameters
start() shipped in 10.5.0; the $resume_identifier and $entries_written
parameters were added in 11.0.0 for chunked generation. Add the matching
@since line so the docblock records when the signature changed, consistent
with flush() and delete() in the same interface.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Keep the "chunk" concept out of ProductWalker
A chunk is the AsyncGenerator's unit of work (one Action Scheduler action);
the walker just processes a bounded number of batches, and its signature
(`$start_page`, `$max_batches`) is already generic. Rename the local
`$batches_in_chunk` counter to `$batches_processed` and drop the chunk
references from the walk() docblock and loop comment so the walker no longer
leaks a caller-level concept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Keep chunked POS feed generation backward compatible (#66112)
* Restore FeedInterface BC via a separate ResumableFeedInterface
The chunked POS catalog generation changed the released FeedInterface in
three backward-incompatible ways (start() signature, plus required
flush() and delete()), which fatals external implementers such as the
WooCommerce Stripe Gateway -- the same class of break that reverted
10.9.0.
Revert FeedInterface to its released shape and move the chunked lifecycle
to a new additive ResumableFeedInterface (open/flush/delete/
get_entry_count) that JsonFileFeed also implements. Restore the
lifecycle-owning ProductWalker::walk(): int and add the bounded
walk_batches(): WalkerProgress alongside it. AsyncGenerator and the POS
feed factory work against the resumable contract.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Update plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php
Co-authored-by: Jaclyn Chen <watertranquil@gmail.com>
* Update plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php
Co-authored-by: Jaclyn Chen <watertranquil@gmail.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jaclyn Chen <watertranquil@gmail.com>
* Hold feed lock until fclose() flushes buffered bytes
end() and flush() released the advisory lock with LOCK_UN before fclose().
fclose() is what flushes PHP's userspace stream buffer to the OS, so between
the unlock and the close another process could acquire the lock and start
writing while this process's buffered bytes were still pending — a narrow
window that could still interleave the two writers' output, the exact
corruption the lock is meant to prevent.
Drop the explicit LOCK_UN in both methods and let fclose() release the lock:
it flushes the buffer and only then unlocks when it closes the descriptor, so
the lock stays held until the bytes are durable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Claim feed job in-progress only after the lock is held
feed_generation_action() persisted state = in_progress and refreshed
updated_at before start()/open() attempted the feed lock. A run that then
lost the lock and stepped aside on FeedLockException had already rewritten the
status and bumped the heartbeat, contradicting that handler's "leave the
status untouched" intent and refreshing the heartbeat of a job it never wrote.
Move the in-progress status write to inside the try, after open() acquires the
lock, so a run that loses the lock returns without touching the persisted
status. The first-chunk file_name/page/counters and the state/updated_at write
now happen in a single update_option() once the lock is secured.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Assert FeedLockException in feed file lock test
The test asserting an overlapping writer cannot acquire the feed lock caught
the generic \Exception, so it would pass on any failure from start(). Assert on
the specific FeedLockException so the test verifies the lock-contention path
rather than an unrelated error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Guard chunk flush on ResumableFeedInterface in lock handler
The FeedLockException handler guarded its flush() call with
`instanceof FeedInterface`, but flush() is declared on ResumableFeedInterface,
not FeedInterface. Match the sibling \Throwable handler and check
ResumableFeedInterface so the guard actually reflects the contract that
declares flush(). Drop the now-unused FeedInterface import.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Abort fresh feed open if truncate/rewind fails
'c' mode doesn't truncate, so an unchecked ftruncate/rewind failure leaves
stale bytes that corrupt the JSON feed. Bail instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Close feed handle when lock acquisition fails
open_handle() stores the handle before acquire_lock() runs, so a thrown
FeedLockException left the instance holding an open handle. Close and clear
it before throwing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Test open() lock path instead of no-arg start()
The lock tests called start(), which takes no args and returns void, so the
identifier was null and the resume path was never exercised — one test was
vacuous and the other only threw via a same-second filename collision. Use
open()/open($identifier) so the resume-lock behavior is actually tested.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
Co-authored-by: Jaclyn Chen <watertranquil@gmail.com>
diff --git a/plugins/woocommerce/changelog/pos-catalog-feed-file-lock b/plugins/woocommerce/changelog/pos-catalog-feed-file-lock
new file mode 100644
index 00000000000..72bbf7ec83c
--- /dev/null
+++ b/plugins/woocommerce/changelog/pos-catalog-feed-file-lock
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Lock the POS catalog feed file exclusively while writing so two overlapping generation processes can no longer interleave output into a corrupt feed.
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/FeedLockException.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/FeedLockException.php
new file mode 100644
index 00000000000..8ae78bb4b40
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/FeedLockException.php
@@ -0,0 +1,22 @@
+<?php
+/**
+ * Feed Lock Exception.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;
+
+use Exception;
+
+/**
+ * Thrown when a feed file cannot be locked because another generation process already holds the lock.
+ *
+ * This is distinct from a genuine generation failure: it means another process owns the feed and is
+ * actively writing it, so the caller should step aside rather than mark the job as failed.
+ *
+ * @since 11.0.0
+ */
+class FeedLockException extends Exception {}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php
index 52f2d1acfbf..8c631883fe4 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php
@@ -11,6 +11,7 @@ namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;
use ActionScheduler_AsyncRequest_QueueRunner;
use ActionScheduler_Store;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedLockException;
use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductWalker;
use Automattic\WooCommerce\Internal\ProductFeed\Feed\ResumableFeedInterface;
use Automattic\WooCommerce\Internal\ProductFeed\Feed\WalkerProgress;
@@ -213,10 +214,6 @@ class AsyncGenerator {
return;
}
- $status['state'] = self::STATE_IN_PROGRESS;
- $status['updated_at'] = time();
- update_option( $option_key, $status );
-
$feed = null;
try {
$this->raise_resource_limits();
@@ -228,11 +225,18 @@ class AsyncGenerator {
$status['page'] = 1;
$status['processed'] = 0;
$status['entries_written'] = 0;
- update_option( $option_key, $status );
} else {
$feed->open( (string) $status['file_name'], (int) ( $status['entries_written'] ?? 0 ) );
}
+ // Only now that the feed lock is held (acquired by open()) do we claim the job as in-progress
+ // and refresh the heartbeat. Doing it earlier would rewrite the status — and bump updated_at —
+ // even for a run that immediately steps aside on FeedLockException, contradicting that path's
+ // "leave the status untouched" intent and refreshing the heartbeat of a job it never wrote.
+ $status['state'] = self::STATE_IN_PROGRESS;
+ $status['updated_at'] = time();
+ update_option( $option_key, $status );
+
$walker = ProductWalker::from_integration( $this->integration, $feed );
$walker->set_batch_size( $this->get_batch_size() );
$walker->add_time_limit( $this->get_batch_time_limit() );
@@ -283,6 +287,17 @@ class AsyncGenerator {
update_option( $option_key, $status );
$this->schedule_generation_action( $option_key );
}
+ } catch ( FeedLockException $e ) {
+ // Another process already holds the feed file lock and is actively writing it, so this run
+ // is a redundant duplicate (e.g. Action Scheduler re-ran a slow chunk while the original was
+ // still in flight). Step aside and leave the status untouched: the lock holder is making
+ // progress, so marking the job failed here would report a failure for a healthy generation
+ // and let the next poll discard the partial file the holder is still writing. Release only
+ // this run's own handle (which never acquired the lock, so closing it cannot free the holder's).
+ if ( $feed instanceof ResumableFeedInterface ) {
+ $feed->flush();
+ }
+ return;
} catch ( \Throwable $e ) {
wc_get_logger()->error(
'Feed generation failed',
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php b/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php
index 65c7f7fd4fa..9c1a779d3f9 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php
@@ -11,6 +11,7 @@ namespace Automattic\WooCommerce\Internal\ProductFeed\Storage;
use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedLockException;
use Automattic\WooCommerce\Internal\ProductFeed\Feed\ResumableFeedInterface;
use Exception;
@@ -117,7 +118,8 @@ class JsonFileFeed implements ResumableFeedInterface {
* @param string|null $resume_identifier Identifier of an existing feed to resume, or null to start fresh.
* @param int $entries_written The number of entries already written by previous chunks.
* @return string The identifier of the feed that was started.
- * @throws Exception If the feed directory or file cannot be created/opened, or a resumed feed is missing.
+ * @throws Exception If the feed directory or file cannot be created/opened, a resumed feed is missing,
+ * or the feed file is already locked by another generation process (FeedLockException).
*/
public function open( ?string $resume_identifier = null, int $entries_written = 0 ): string {
$upload_dir = $this->get_upload_dir();
@@ -157,7 +159,8 @@ class JsonFileFeed implements ResumableFeedInterface {
// Seed the entry count so add_entry()'s separator accounts for entries already written.
$this->entry_count = $entries_written;
- $this->open_handle( $this->file_path, 'a' );
+ $handle = $this->open_handle( $this->file_path, 'a' );
+ $this->acquire_lock( $handle );
return $this->file_name;
}
@@ -165,7 +168,28 @@ class JsonFileFeed implements ResumableFeedInterface {
$this->entry_count = 0;
$this->file_name = $this->generate_file_name();
$this->file_path = $upload_dir['path'] . $this->file_name;
- $handle = $this->open_handle( $this->file_path, 'w' );
+
+ // Open with 'c' (create, do not truncate) rather than 'w' so the exclusive lock is acquired
+ // *before* any existing content is cleared. An overlapping process that fails to get the lock
+ // must not truncate a feed the lock holder is still writing; only once the lock is held is it
+ // safe to clear stale content and write the array from the top.
+ $handle = $this->open_handle( $this->file_path, 'c' );
+ $this->acquire_lock( $handle );
+
+ if ( ! ftruncate( $handle, 0 ) || ! rewind( $handle ) ) {
+ fclose( $handle );
+ $this->file_handle = null;
+ throw new Exception(
+ esc_html(
+ sprintf(
+ /* translators: %s: file path */
+ __( 'Unable to reset feed file for writing: %s', 'woocommerce' ),
+ $this->file_path
+ )
+ )
+ );
+ }
+
fwrite( $handle, '[' );
return $this->file_name;
@@ -204,6 +228,10 @@ class JsonFileFeed implements ResumableFeedInterface {
}
fwrite( $this->file_handle, ']' );
+ // Do not unlock explicitly before closing: fclose() flushes PHP's userspace stream buffer to the
+ // OS and only then releases the lock. Unlocking first would open a window in which another process
+ // could acquire the lock and start writing while this process's buffered bytes are still pending,
+ // interleaving output — the exact corruption the lock prevents.
fclose( $this->file_handle );
$this->file_handle = null;
$this->file_completed = true;
@@ -214,6 +242,9 @@ class JsonFileFeed implements ResumableFeedInterface {
*/
public function flush(): void {
if ( is_resource( $this->file_handle ) ) {
+ // Rely on fclose() to release the lock: it flushes the userspace buffer first and only then
+ // unlocks, so the lock stays held until the buffered bytes are durable. An explicit LOCK_UN
+ // here would release it while pending bytes are still buffered (see end()).
fclose( $this->file_handle );
$this->file_handle = null;
}
@@ -293,6 +324,40 @@ class JsonFileFeed implements ResumableFeedInterface {
return $handle;
}
+ /**
+ * Acquires an exclusive, non-blocking lock on the open feed file handle.
+ *
+ * A feed file is written across separate, short-lived processes (one Action Scheduler action per
+ * chunk). A stuck in-progress job can be treated as stuck and have a fresh generation enqueued
+ * while the original is still running, so two processes can end up writing the same file at once;
+ * without mutual exclusion their unbuffered writes interleave into malformed JSON. The lock makes
+ * each chunk's write to the shared file exclusive, so overlapping generations cannot interleave.
+ *
+ * The lock is advisory, which is sufficient because every writer goes through this class. It is
+ * released when the handle is closed in flush()/end(), including when a process is killed and the
+ * OS closes its descriptors — so a killed job leaves no stale lock, and its partial file is handled
+ * by the heartbeat-based stuck-job recovery instead.
+ *
+ * @param resource $handle The open feed file handle.
+ * @return void
+ * @throws FeedLockException If the lock is already held by another process.
+ */
+ private function acquire_lock( $handle ): void {
+ if ( ! flock( $handle, LOCK_EX | LOCK_NB ) ) {
+ fclose( $handle );
+ $this->file_handle = null;
+ throw new FeedLockException(
+ esc_html(
+ sprintf(
+ /* translators: %s: file path */
+ __( 'Feed file is locked by another generation process: %s', 'woocommerce' ),
+ $this->file_path
+ )
+ )
+ );
+ }
+ }
+
/**
* Generate the feed file name based on the base name and the current time.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGeneratorTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGeneratorTest.php
index 3b9f1fed419..f63a2fa0472 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGeneratorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGeneratorTest.php
@@ -707,6 +707,56 @@ class AsyncGeneratorTest extends \WC_Unit_Test_Case {
delete_option( $option_key );
}
+ /**
+ * @testdox Should leave the job in progress (not failed) and keep the partial feed when the feed file is locked by another process.
+ */
+ public function test_feed_generation_steps_aside_when_feed_file_is_locked() {
+ $this->mock_integration->method( 'create_feed' )->willReturnCallback(
+ fn() => new JsonFileFeed( 'pos-catalog-feed-test' )
+ );
+
+ // Simulate the original process: start a feed and keep its exclusive lock held (no flush/end).
+ $holder = new JsonFileFeed( 'pos-catalog-feed-test' );
+ $identifier = $holder->start();
+ $partial_path = wp_upload_dir()['basedir'] . '/' . JsonFileFeed::UPLOAD_DIR . '/' . $identifier;
+ $this->assertTrue( file_exists( $partial_path ) );
+
+ // A continuation for the same feed, as a duplicate run would see it.
+ update_option(
+ self::OPTION_KEY,
+ array(
+ 'state' => AsyncGenerator::STATE_IN_PROGRESS,
+ 'file_name' => $identifier,
+ 'page' => 2,
+ 'processed' => 1,
+ 'entries_written' => 1,
+ 'total' => 5,
+ 'updated_at' => time(),
+ )
+ );
+
+ try {
+ $this->sut->feed_generation_action( self::OPTION_KEY );
+ } finally {
+ // Release the holder's lock so fixtures can be cleaned up.
+ $holder->flush();
+ }
+
+ $status = get_option( self::OPTION_KEY );
+ $this->assertSame(
+ AsyncGenerator::STATE_IN_PROGRESS,
+ $status['state'],
+ 'A locked feed file means another process is generating; the job must not be marked failed.'
+ );
+ $this->assertArrayNotHasKey( 'error', $status );
+ $this->assertTrue(
+ file_exists( $partial_path ),
+ 'The partial feed the lock holder is still writing must not be discarded.'
+ );
+
+ wp_delete_file( $partial_path );
+ }
+
/**
* Test that feed generation records a heartbeat in the resulting status.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php
index 08292c3280c..82adf96c0dd 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php
@@ -3,6 +3,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Storage;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedLockException;
use Automattic\WooCommerce\Internal\ProductFeed\Storage\JsonFileFeed;
use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
@@ -160,6 +161,40 @@ class JsonFileFeedTest extends \WC_Unit_Test_Case {
( new JsonFileFeed( 'test-feed' ) )->open( 'does-not-exist.json' );
}
+ /**
+ * @testdox Should throw when another process already holds the feed file lock, so overlapping writes cannot interleave.
+ */
+ public function test_open_throws_when_feed_file_is_locked_by_another_process() {
+ $feed = new JsonFileFeed( 'test-feed' );
+ $identifier = $feed->open();
+
+ $feed_two = new JsonFileFeed( 'test-feed' );
+
+ try {
+ $this->expectException( FeedLockException::class );
+ $feed_two->open( $identifier );
+ } finally {
+ $feed->flush();
+ }
+ }
+
+ /**
+ * @testdox Should let a later process write the feed once the previous process releases the lock.
+ */
+ public function test_open_succeeds_after_feed_file_lock_is_released() {
+ $feed = new JsonFileFeed( 'test-feed' );
+ $identifier = $feed->open();
+ $feed->flush();
+
+ $feed_two = new JsonFileFeed( 'test-feed' );
+ $this->assertSame(
+ $identifier,
+ $feed_two->open( $identifier ),
+ 'A resumed feed should acquire the lock once the previous holder released it.'
+ );
+ $feed_two->flush();
+ }
+
/**
* Test that delete removes a partial feed file.
*/