Skip to content

Commit 979bd1a

Browse files
mjoslynclaude
andcommitted
feat(export): run pricelist export in the background via Action Scheduler
The full per-user pricelist is far too large to build in one request, so the cron job and the "Generate and email now" button were timing out. Move both to Action Scheduler (bundled with WooCommerce): capture the product list once, then process users in bounded batches, emailing the assembled CSV after the last batch. - PricelistExporter: split out email_file(); add batch primitives product_refs(), user_refs_page(), init_csv(), append_rows(). Pass the explicit fputcsv escape arg (silences the PHP 8.4 deprecation; output unchanged). - ExportModule: start_background() sizes user batches to ~wc_pricebook_export_batch_rows (default 5,000) rows, writes the header, and queues the first batch; run_batch() processes a page and re-queues, or emails + cleans up when users run out. Single active run (guarded); stale/failed runs are reset. Synchronous fallback when Action Scheduler is unavailable. run_cron() and the Send-now button now start a background run; new "started"/"running" admin notices. CLI gains --async. - Plugin: on_deactivation clears queued batches + run state. - Docs/README: document background processing, --async, and wc_pricebook_export_batch_rows. - Tests: init_csv + append_rows round-trip. 120 passing. Verified end-to-end on a live store: batches processed via `wp action-scheduler run`, final batch emailed and cleaned up, no wp_mail failure logged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 06323e0 commit 979bd1a

7 files changed

Lines changed: 418 additions & 33 deletions

File tree

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,13 @@ Three ways to run it, all production‑safe:
214214

215215
The **recipient** defaults to the signed‑in admin's email (falling back to the site
216216
`admin_email` when blank), and an optional **role filter** limits the export to users in
217-
selected roles. Users and products are gathered in pages so the command scales on large
218-
stores.
217+
selected roles.
218+
219+
The scheduled run and the **Generate and email now** button run **in the background via
220+
Action Scheduler** (bundled with WooCommerce): users are processed in bounded batches so
221+
no single request builds the whole file — this is what keeps a large store from timing
222+
out. The email arrives when the last batch finishes. WP‑CLI stays synchronous by default
223+
(no request timeout); pass `--async` to use the background batches instead.
219224

220225
## Multi-account / sub-accounts
221226

docs/reference/filters.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Used by the [pricelist export](/reference/pricelist-export):
4747
| `wc_pricebook_export_settings` | Recipient / schedule / role filter |
4848
| `wc_pricebook_export_product_ids` | The product refs included in the export |
4949
| `wc_pricebook_export_user_query` | The `WP_User_Query` args used to gather users |
50+
| `wc_pricebook_export_batch_rows` | Target CSV rows per background (Action Scheduler) batch — default 5,000 |
5051

5152
## Example
5253

docs/reference/pricelist-export.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ wp wc-pricebook export-pricelist --roles=dealer,operator --send
4343
| `--email=<address>` | Email the CSV to this address as an attachment. |
4444
| `--roles=<slugs>` | Comma-separated WP roles to limit the export to (default: every user). |
4545
| `--send` | Email the CSV to the configured recipient (or the site admin). |
46+
| `--async` | Queue the run through Action Scheduler (background batches) and email it when done. |
4647

4748
### Scheduled (WP-Cron)
4849

@@ -52,8 +53,26 @@ whenever the settings are saved, and is cleared on plugin deactivation.
5253

5354
### On demand
5455

55-
The **Generate and email now** button on that settings page builds the CSV immediately
56-
and emails it to the recipient.
56+
The **Generate and email now** button on that settings page starts the export in the
57+
background and returns immediately; the email arrives when it finishes.
58+
59+
### Background processing
60+
61+
The scheduled run and the **Generate and email now** button run **in the background via
62+
[Action Scheduler](https://actionscheduler.org/)** (bundled with WooCommerce): the
63+
product list is captured once, then users are processed in bounded batches so no single
64+
request builds the whole file. This is what keeps a large store from timing out — a full
65+
export is far too big for one PHP request.
66+
67+
- Batches are sized so `users-per-batch × products ≈ 5,000` rows; tune with the
68+
[`wc_pricebook_export_batch_rows`](/reference/filters#pricelist-export) filter.
69+
- Batches run as Action Scheduler jobs, which are processed by WP-Cron (site traffic) or
70+
a real cron pinging `wp-cron.php` / `wp action-scheduler run`. On a very low-traffic
71+
site, make sure something is driving the queue.
72+
- Only one export runs at a time; starting another while one is in progress is ignored
73+
until it finishes.
74+
- WP-CLI stays **synchronous** by default (no request timeout). Pass `--async` to route a
75+
CLI run through the same background batches instead.
5776

5877
## Settings
5978

@@ -85,3 +104,4 @@ explicitly on the product if you need a figure in the export.
85104
| `wc_pricebook_export_settings` | Recipient / schedule / role filter |
86105
| `wc_pricebook_export_product_ids` | The product refs (`id`, `name`, `sku`) included |
87106
| `wc_pricebook_export_user_query` | The `WP_User_Query` args used to gather users |
107+
| `wc_pricebook_export_batch_rows` | Target CSV rows per background batch (default 5,000) |

src/Export/ExportModule.php

Lines changed: 242 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@
22
/**
33
* WordPress integration for the pricelist export.
44
*
5-
* Wires {@see PricelistExporter} to the three ways a store runs it:
6-
* - WP-CLI: `wp wc-pricebook export-pricelist [--file] [--email] [--roles] [--send]`
5+
* Wires {@see PricelistExporter} to the ways a store runs it:
6+
* - WP-CLI: `wp wc-pricebook export-pricelist [--file] [--email] [--roles] [--send] [--async]`
77
* - Cron: a daily/weekly scheduled event that emails the configured recipient.
88
* - Admin: the settings-page "Send now" button (admin-post handler).
99
*
10+
* Cron and the "Send now" button run the export **in the background** via Action
11+
* Scheduler (bundled with WooCommerce): the product list is captured once, then users
12+
* are processed in bounded batches so no single request builds the whole file — this is
13+
* what stops a large store from timing out. The finished CSV is emailed after the last
14+
* batch. When Action Scheduler is unavailable the run falls back to synchronous mode.
15+
* The WP-CLI command stays synchronous by default (no request timeout).
16+
*
1017
* The cron schedule is (re)synced whenever the config option is saved. Nothing here
1118
* touches pricing logic — it only orchestrates when/where the CSV is produced and sent.
1219
*
@@ -37,6 +44,22 @@ class ExportModule {
3744
*/
3845
const ACTION_NOW = 'wc_pricebook_export_now';
3946

47+
/**
48+
* Action Scheduler hook for a single background batch of users.
49+
*/
50+
const BATCH_HOOK = 'wc_pricebook_export_batch';
51+
52+
/**
53+
* Option holding the in-progress run's state ({ run, file, recipient, roles, page,
54+
* per_page, rows }). Non-autoloaded.
55+
*/
56+
const STATE_OPTION = 'wc_pricebook_export_state';
57+
58+
/**
59+
* Option holding the run's captured product refs. Non-autoloaded.
60+
*/
61+
const PRODUCTS_OPTION = 'wc_pricebook_export_products';
62+
4063
/**
4164
* Config provider.
4265
*
@@ -70,6 +93,7 @@ public function __construct( Config $config, PriceEngine $engine ) {
7093
*/
7194
public function register() {
7295
add_action( self::CRON_HOOK, array( $this, 'run_cron' ) );
96+
add_action( self::BATCH_HOOK, array( $this, 'run_batch' ), 10, 1 );
7397
add_action( 'update_option_' . Config::OPTION, array( $this, 'reschedule' ), 10, 2 );
7498
add_action( 'add_option_' . Config::OPTION, array( $this, 'reschedule_added' ), 10, 2 );
7599
add_action( 'admin_post_' . self::ACTION_NOW, array( $this, 'handle_send_now' ) );
@@ -81,14 +105,200 @@ public function register() {
81105
}
82106

83107
/**
84-
* The scheduled cron callback: build the pricelist and email the configured
85-
* recipient (or the site admin), then clean up the temp file.
108+
* The scheduled cron callback: start a background export to the configured recipient
109+
* (or the site admin). The heavy work runs in Action Scheduler batches, not in this
110+
* (WP-Cron) request.
86111
*
87112
* @return void
88113
*/
89114
public function run_cron() {
90-
$result = $this->exporter->run_and_email( $this->recipient(), array( 'roles' => $this->config->export()['roles'] ) );
91-
$this->cleanup( $result['file'] );
115+
$this->start_background( $this->recipient(), $this->config->export()['roles'] );
116+
}
117+
118+
/**
119+
* Whether Action Scheduler (bundled with WooCommerce) is available to run the export
120+
* in the background. When absent, callers fall back to a synchronous run.
121+
*
122+
* @return bool
123+
*/
124+
private function async_available() {
125+
return function_exists( 'as_enqueue_async_action' ) && function_exists( 'as_has_scheduled_action' );
126+
}
127+
128+
/**
129+
* Whether a background export is currently in progress (state present and a batch
130+
* still queued).
131+
*
132+
* @return bool
133+
*/
134+
private function is_running() {
135+
return false !== get_option( self::STATE_OPTION, false )
136+
&& $this->async_available()
137+
&& as_has_scheduled_action( self::BATCH_HOOK );
138+
}
139+
140+
/**
141+
* Start a background export: capture the product list, size the user batches to a
142+
* target row count, write the CSV header, and queue the first batch. Each batch runs
143+
* as an Action Scheduler job, so no single request builds the whole file.
144+
*
145+
* Falls back to a synchronous run + email when Action Scheduler is unavailable.
146+
*
147+
* @param string $recipient Recipient email.
148+
* @param array<int,string> $roles Role filter (empty = all users).
149+
* @return string Status: 'started', 'running', 'norecipient', 'sent', 'mailfail', or 'error'.
150+
*/
151+
public function start_background( $recipient, array $roles ) {
152+
if ( '' === (string) $recipient || ! is_email( $recipient ) ) {
153+
return 'norecipient';
154+
}
155+
156+
// No Action Scheduler (e.g. WooCommerce inactive): run synchronously as before.
157+
if ( ! $this->async_available() ) {
158+
try {
159+
$result = $this->exporter->run_and_email( $recipient, array( 'roles' => $roles ) );
160+
$this->cleanup( $result['file'] );
161+
return $result['sent'] ? 'sent' : 'mailfail';
162+
} catch ( \RuntimeException $e ) {
163+
$this->log( 'Pricelist export failed: ' . $e->getMessage() );
164+
return 'error';
165+
}
166+
}
167+
168+
if ( $this->is_running() ) {
169+
return 'running';
170+
}
171+
172+
// A stale state with no queued batch (e.g. a previous run died) — reset it.
173+
$this->clear_run();
174+
175+
$products = $this->exporter->product_refs();
176+
$file = $this->run_file();
177+
try {
178+
$this->exporter->init_csv( $file );
179+
} catch ( \RuntimeException $e ) {
180+
$this->log( 'Pricelist export could not create its file: ' . $e->getMessage() );
181+
return 'error';
182+
}
183+
184+
/**
185+
* Filters the target number of CSV rows per background batch. Batches are sized
186+
* so users-per-batch × products ≈ this, keeping each Action Scheduler run well
187+
* inside a request's time/memory budget.
188+
*
189+
* @param int $rows Target rows per batch.
190+
*/
191+
$target = (int) apply_filters( 'wc_pricebook_export_batch_rows', 5000 );
192+
$target = $target > 0 ? $target : 5000;
193+
$per_page = max( 1, (int) floor( $target / max( 1, count( $products ) ) ) );
194+
$run = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'run', true );
195+
196+
add_option( self::PRODUCTS_OPTION, $products, '', 'no' );
197+
add_option(
198+
self::STATE_OPTION,
199+
array(
200+
'run' => $run,
201+
'file' => $file,
202+
'recipient' => $recipient,
203+
'roles' => array_values( $roles ),
204+
'page' => 1,
205+
'per_page' => $per_page,
206+
'rows' => 0,
207+
),
208+
'',
209+
'no'
210+
);
211+
212+
as_enqueue_async_action( self::BATCH_HOOK, array( 'run' => $run ), 'wc-pricebook' );
213+
return 'started';
214+
}
215+
216+
/**
217+
* Process one batch of users (each priced against every product), then queue the
218+
* next — or, when the users run out, email the finished CSV and clean up. Hooked to
219+
* {@see self::BATCH_HOOK} via Action Scheduler.
220+
*
221+
* @param string $run Run token; a mismatch means the action is stale (superseded).
222+
* @return void
223+
*/
224+
public function run_batch( $run = '' ) {
225+
$state = get_option( self::STATE_OPTION, false );
226+
if ( ! is_array( $state ) || ( '' !== (string) $run && ( $state['run'] ?? '' ) !== $run ) ) {
227+
return; // Stale or superseded action.
228+
}
229+
230+
try {
231+
$products = get_option( self::PRODUCTS_OPTION, array() );
232+
$products = is_array( $products ) ? $products : array();
233+
234+
$users = $this->exporter->user_refs_page( (array) $state['roles'], (int) $state['page'], (int) $state['per_page'] );
235+
236+
if ( empty( $users ) ) {
237+
// Done — email the assembled file, then clean up.
238+
$sent = $this->exporter->email_file( $state['recipient'], $state['file'], (int) $state['rows'] );
239+
if ( ! $sent ) {
240+
$this->log( sprintf( 'Pricelist export assembled %d rows but wp_mail() failed to send to %s.', (int) $state['rows'], $state['recipient'] ) );
241+
}
242+
$this->cleanup( $state['file'] );
243+
$this->clear_run();
244+
return;
245+
}
246+
247+
$state['rows'] += $this->exporter->append_rows( $state['file'], $users, $products );
248+
$state['page'] = (int) $state['page'] + 1;
249+
update_option( self::STATE_OPTION, $state, false );
250+
251+
as_enqueue_async_action( self::BATCH_HOOK, array( 'run' => $state['run'] ), 'wc-pricebook' );
252+
} catch ( \Throwable $e ) {
253+
$this->log( 'Pricelist export batch failed: ' . $e->getMessage() );
254+
$this->cleanup( isset( $state['file'] ) ? $state['file'] : '' );
255+
$this->clear_run();
256+
}
257+
}
258+
259+
/**
260+
* Remove the run state + captured product list, and drop any queued batches.
261+
*
262+
* @return void
263+
*/
264+
private function clear_run() {
265+
delete_option( self::STATE_OPTION );
266+
delete_option( self::PRODUCTS_OPTION );
267+
if ( function_exists( 'as_unschedule_all_actions' ) ) {
268+
as_unschedule_all_actions( self::BATCH_HOOK );
269+
}
270+
}
271+
272+
/**
273+
* A per-run CSV path under uploads/wc-pricebook (persists across batch requests).
274+
*
275+
* @return string
276+
*/
277+
private function run_file() {
278+
if ( function_exists( 'wp_upload_dir' ) ) {
279+
$uploads = wp_upload_dir();
280+
if ( empty( $uploads['error'] ) ) {
281+
$dir = rtrim( $uploads['basedir'], '/\\' ) . '/wc-pricebook';
282+
wp_mkdir_p( $dir );
283+
return $dir . '/pricelist-' . gmdate( 'Ymd-His' ) . '.csv';
284+
}
285+
}
286+
$tmp = function_exists( 'get_temp_dir' ) ? get_temp_dir() : sys_get_temp_dir() . '/';
287+
return rtrim( $tmp, '/\\' ) . '/wc-pricebook-pricelist-' . gmdate( 'Ymd-His' ) . '.csv';
288+
}
289+
290+
/**
291+
* Log a message (WooCommerce logger when present, else the PHP error log).
292+
*
293+
* @param string $message Message.
294+
* @return void
295+
*/
296+
private function log( $message ) {
297+
if ( function_exists( 'wc_get_logger' ) ) {
298+
wc_get_logger()->error( $message, array( 'source' => 'wc-pricebook-export' ) );
299+
return;
300+
}
301+
error_log( '[wc-pricebook] ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
92302
}
93303

94304
/**
@@ -160,18 +370,9 @@ public function handle_send_now() {
160370
$recipient = $this->recipient();
161371
}
162372

163-
$status = 'sent';
164-
if ( '' === $recipient ) {
165-
$status = 'norecipient';
166-
} else {
167-
try {
168-
$result = $this->exporter->run_and_email( $recipient, array( 'roles' => $this->config->export()['roles'] ) );
169-
$this->cleanup( $result['file'] );
170-
$status = $result['sent'] ? 'sent' : 'mailfail';
171-
} catch ( \RuntimeException $e ) {
172-
$status = 'error';
173-
}
174-
}
373+
// Queue the export in the background (synchronous fallback if Action Scheduler
374+
// is unavailable). The button returns immediately; the email follows.
375+
$status = $this->start_background( $recipient, $this->config->export()['roles'] );
175376

176377
$redirect = add_query_arg(
177378
array(
@@ -196,6 +397,8 @@ public function maybe_notice() {
196397
$status = sanitize_key( wp_unslash( $_GET['wc_pricebook_export'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
197398

198399
$messages = array(
400+
'started' => array( 'success', __( 'Pricelist export started. It runs in the background — you’ll get an email with the CSV when it finishes.', 'wc-pricebook' ) ),
401+
'running' => array( 'warning', __( 'A pricelist export is already running. You’ll get the email when it finishes.', 'wc-pricebook' ) ),
199402
'sent' => array( 'success', __( 'Pricelist export emailed.', 'wc-pricebook' ) ),
200403
'mailfail' => array( 'error', __( 'The pricelist CSV was generated but the email could not be sent. Check the site’s mail configuration.', 'wc-pricebook' ) ),
201404
'norecipient' => array( 'error', __( 'No recipient email is set. Enter one in the Pricelist export settings.', 'wc-pricebook' ) ),
@@ -229,11 +432,16 @@ public function maybe_notice() {
229432
* : Email the CSV to the configured recipient (or the site admin) instead of / in
230433
* addition to writing a file.
231434
*
435+
* [--async]
436+
* : Queue the export via Action Scheduler (background batches) and email it when done,
437+
* instead of building it inline. Uses --email or the configured recipient.
438+
*
232439
* ## EXAMPLES
233440
*
234441
* wp wc-pricebook export-pricelist --file=/tmp/pricelist.csv
235442
* wp wc-pricebook export-pricelist --email=sales@example.com
236443
* wp wc-pricebook export-pricelist --roles=dealer,operator --send
444+
* wp wc-pricebook export-pricelist --async --email=sales@example.com
237445
*
238446
* @param array<int,string> $args Positional args (unused).
239447
* @param array<string,string> $assoc_args Flags.
@@ -249,6 +457,22 @@ public function cli_export( $args, $assoc_args ) {
249457
$email = isset( $assoc_args['email'] ) ? (string) $assoc_args['email'] : '';
250458
$send = isset( $assoc_args['send'] );
251459

460+
// Background mode: queue Action Scheduler batches and let them email when done.
461+
if ( isset( $assoc_args['async'] ) ) {
462+
$recipient = '' !== $email ? $email : $this->recipient();
463+
$status = $this->start_background( $recipient, $roles );
464+
if ( in_array( $status, array( 'started', 'sent' ), true ) ) {
465+
\WP_CLI::success( sprintf( 'Queued the background pricelist export to %s. Run the Action Scheduler queue (e.g. `wp action-scheduler run`) to process it.', $recipient ) );
466+
} elseif ( 'running' === $status ) {
467+
\WP_CLI::warning( 'A background pricelist export is already running.' );
468+
} elseif ( 'norecipient' === $status ) {
469+
\WP_CLI::error( 'No valid recipient. Pass --email=<address> or configure a recipient in Pricebook settings.' );
470+
} else {
471+
\WP_CLI::error( 'Could not start the background export. Check the log.' );
472+
}
473+
return;
474+
}
475+
252476
try {
253477
// Emailing: use the recipient flag, else the configured/admin recipient.
254478
if ( '' !== $email || $send ) {

0 commit comments

Comments
 (0)