diff --git a/src/post/class-sync.php b/src/post/class-sync.php index 3c977e0e..78b01da6 100644 --- a/src/post/class-sync.php +++ b/src/post/class-sync.php @@ -47,6 +47,21 @@ class Sync { */ const DELETE_AUDIO_CRON_HOOK = 'beyondwords_delete_audio'; + /** + * Maximum number of posts the bulk "Generate audio" action processes + * synchronously in a single admin request when async generation is + * unavailable (i.e. off VIP). + * + * Off VIP each post is a blocking API call, so an unbounded loop over a large + * selection could run the request past the PHP/host execution limit. We cap + * the count and defer the remainder (the caller surfaces a notice). Sized + * against `\BeyondWords\Api\Client::DEFAULT_REQUEST_TIMEOUT` so the whole + * batch stays well inside a typical execution limit even in the worst case. + * + * @since 7.0.0 + */ + const BULK_GENERATE_SYNC_LIMIT = 10; + /** * Deprecated post-meta keys that still need REST exposure. * @@ -250,6 +265,8 @@ public static function is_async_generation_enabled(): bool { * audio (with 404 recovery via `update_or_recreate_audio()`) or create * fresh content. * + * @param int $post_id WordPress post ID. + * * @return array|false|null Response from the API, or false when audio wasn't generated. */ public static function generate_audio_for_post( int $post_id ): array|false|null { @@ -300,6 +317,8 @@ public static function generate_audio_for_post( int $post_id ): array|false|null * `\BeyondWords\Api\Client::update_audio()` writes `#404:…` into the error meta. We * detect that prefix, clear the stale ID, and create fresh content via * POST so the post recovers automatically. + * + * @param int $post_id WordPress post ID. */ private static function update_or_recreate_audio( int $post_id ): array|null|false { $response = \BeyondWords\Api\Client::update_audio( $post_id ); @@ -333,6 +352,108 @@ public static function batch_delete_audio_for_posts( array $post_ids ): array|fa return \BeyondWords\Api\Client::batch_delete_audio( $post_ids ); } + /** + * Dispatch bulk "Generate audio" for a set of posts without blocking the + * admin request on an unbounded run of remote API calls. + * + * Every selected post is flagged for generation (a cheap meta write). Then: + * + * - On VIP (async enabled) each post is queued as a background cron job, so + * the request returns immediately and no API call runs inline. All selected + * posts are reported as "generated" (i.e. requested). + * - Off VIP (async disabled) we can't rely on WP-Cron, so generation runs + * inline — but capped at `BULK_GENERATE_SYNC_LIMIT` posts, so a slow API + * can't run the request past the execution limit. Each call is already + * bounded by `\BeyondWords\Api\Client::DEFAULT_REQUEST_TIMEOUT`, so the cap + * is what keeps the total bounded. Posts still missing audio are processed + * before regenerations, and any beyond the cap are returned as "deferred" + * for the caller to surface. + * + * @since 7.0.0 + * + * @param int[] $post_ids WordPress post IDs from the bulk selection. + * + * @return array{generated:int, failed:int, deferred:int} Per-outcome counts. + */ + public static function bulk_generate_audio_for_posts( array $post_ids ): array { + $post_ids = array_map( 'intval', $post_ids ); + $post_ids = array_values( array_unique( array_filter( $post_ids, static fn( int $id ): bool => $id > 0 ) ) ); + sort( $post_ids ); + + // Flag every selected post for generation (cheap). On the async path this + // is what the deferred cron job reads; off VIP it records intent for the + // posts we can't process in this request. + foreach ( $post_ids as $post_id ) { + update_post_meta( $post_id, 'beyondwords_generate_audio', '1' ); + } + + // VIP: queue a background job per post and return immediately. + if ( self::is_async_generation_enabled() ) { + foreach ( $post_ids as $post_id ) { + self::schedule_audio_generation( $post_id ); + } + + return [ + 'generated' => count( $post_ids ), + 'failed' => 0, + 'deferred' => 0, + ]; + } + + // Off VIP: process synchronously, but hard-capped — each call is already + // bounded by the client's short default timeout, so the cap is what keeps + // the batch total bounded. Posts still missing audio are processed before + // regenerations so re-running the action on a large selection converges. + $ordered = self::order_posts_for_bulk_generation( $post_ids ); + $limit = self::BULK_GENERATE_SYNC_LIMIT; + $to_process = array_slice( $ordered, 0, $limit ); + $deferred = count( $ordered ) - count( $to_process ); + + $generated = 0; + $failed = 0; + + foreach ( $to_process as $post_id ) { + if ( self::generate_audio_for_post( $post_id ) ) { + ++$generated; + } else { + ++$failed; + } + } + + return [ + 'generated' => $generated, + 'failed' => $failed, + 'deferred' => $deferred, + ]; + } + + /** + * Order a bulk selection so posts that still need audio created are processed + * before posts that already have audio (a regeneration). + * + * Keeps the synchronous cap making forward progress: re-running the action on + * a large selection works through the un-generated posts rather than + * re-updating the same already-done ones each time. + * + * @param int[] $post_ids Normalised, sorted post IDs. + * + * @return int[] + */ + private static function order_posts_for_bulk_generation( array $post_ids ): array { + $needs_create = []; + $needs_update = []; + + foreach ( $post_ids as $post_id ) { + if ( \BeyondWords\Post\Meta::get_content_id( $post_id ) ) { + $needs_update[] = $post_id; + } else { + $needs_create[] = $post_id; + } + } + + return array_merge( $needs_create, $needs_update ); + } + /** * Run a deferred audio deletion queued by the trash/delete handlers. * diff --git a/src/posts-list/class-bulk-edit.php b/src/posts-list/class-bulk-edit.php index 6d68baf6..4fc77434 100644 --- a/src/posts-list/class-bulk-edit.php +++ b/src/posts-list/class-bulk-edit.php @@ -257,6 +257,7 @@ public static function handle_bulk_generate_action( $redirect, $doaction, $objec $redirect = remove_query_arg( [ 'beyondwords_bulk_generated', + 'beyondwords_bulk_deferred', 'beyondwords_bulk_deleted', 'beyondwords_bulk_failed', 'beyondwords_bulk_error', @@ -287,30 +288,22 @@ public static function handle_bulk_generate_action( $redirect, $doaction, $objec return add_query_arg( 'beyondwords_bulk_edit_result_nonce', $nonce, $redirect ); } - sort( $object_ids ); - - $generated = 0; - $failed = 0; - + // Dispatch through Sync, which offloads to background cron on VIP and + // runs a hard-capped synchronous batch off VIP — so this admin request + // never blocks on an unbounded run of remote API calls. + // Sync normalises and sorts the IDs, so no sort() is needed here. try { - foreach ( $object_ids as $post_id ) { - update_post_meta( $post_id, 'beyondwords_generate_audio', '1' ); - } + $counts = \BeyondWords\Post\Sync::bulk_generate_audio_for_posts( $object_ids ); + $redirect = add_query_arg( 'beyondwords_bulk_generated', $counts['generated'], $redirect ); + $redirect = add_query_arg( 'beyondwords_bulk_failed', $counts['failed'], $redirect ); - foreach ( $object_ids as $post_id ) { - if ( \BeyondWords\Post\Sync::generate_audio_for_post( $post_id ) ) { - ++$generated; - } else { - ++$failed; - } + if ( $counts['deferred'] > 0 ) { + $redirect = add_query_arg( 'beyondwords_bulk_deferred', $counts['deferred'], $redirect ); } } catch ( \Exception $e ) { $redirect = add_query_arg( 'beyondwords_bulk_error', $e->getMessage(), $redirect ); } - $redirect = add_query_arg( 'beyondwords_bulk_generated', $generated, $redirect ); - $redirect = add_query_arg( 'beyondwords_bulk_failed', $failed, $redirect ); - $nonce = wp_create_nonce( 'beyondwords_bulk_edit_result' ); return add_query_arg( 'beyondwords_bulk_edit_result_nonce', $nonce, $redirect ); @@ -331,6 +324,7 @@ public static function handle_bulk_delete_action( $redirect, $doaction, $object_ $redirect = remove_query_arg( [ 'beyondwords_bulk_generated', + 'beyondwords_bulk_deferred', 'beyondwords_bulk_deleted', 'beyondwords_bulk_failed', 'beyondwords_bulk_error', diff --git a/src/posts-list/class-notices.php b/src/posts-list/class-notices.php index 0c03cd3f..60118f09 100644 --- a/src/posts-list/class-notices.php +++ b/src/posts-list/class-notices.php @@ -26,6 +26,7 @@ class Notices { */ public static function init(): void { add_action( 'admin_notices', [ self::class, 'generated_notice' ] ); + add_action( 'admin_notices', [ self::class, 'deferred_notice' ] ); add_action( 'admin_notices', [ self::class, 'deleted_notice' ] ); add_action( 'admin_notices', [ self::class, 'failed_notice' ] ); add_action( 'admin_notices', [ self::class, 'error_notice' ] ); @@ -58,6 +59,39 @@ public static function generated_notice(): void { +
+

+
+ post->create(['post_status' => 'publish']), + self::factory()->post->create(['post_status' => 'publish']), + self::factory()->post->create(['post_status' => 'publish']), + ]; + + $counts = Sync::bulk_generate_audio_for_posts($postIds); + + // Every post is queued and reported as requested; nothing runs inline. + $this->assertSame(count($postIds), $counts['generated']); + $this->assertSame(0, $counts['failed']); + $this->assertSame(0, $counts['deferred']); + + foreach ($postIds as $postId) { + $this->assertEquals('1', get_post_meta($postId, 'beyondwords_generate_audio', true)); + $this->assertNotFalse(wp_next_scheduled(Sync::GENERATE_AUDIO_CRON_HOOK, [$postId])); + $this->assertSame('', get_post_meta($postId, 'beyondwords_content_id', true)); + } + + foreach ($postIds as $postId) { + wp_unschedule_event(wp_next_scheduled(Sync::GENERATE_AUDIO_CRON_HOOK, [$postId]), Sync::GENERATE_AUDIO_CRON_HOOK, [$postId]); + wp_delete_post($postId, true); + } + remove_filter('beyondwords_async_generate_audio', '__return_true'); + } + + /** + * @test + * @group generateAudio + */ + public function bulk_generate_audio_for_posts_caps_synchronous_batch_off_vip() + { + // Off VIP (no async) the synchronous batch is capped so a large selection + // can't run an unbounded blocking loop. Select two more than the cap and + // derive the expectations from the constant, so this still holds if the + // cap is ever retuned. + $overflow = 2; + $selected = Sync::BULK_GENERATE_SYNC_LIMIT + $overflow; + + // No credentials → each processed post fails before any (mock) API call, + // giving a deterministic count without hitting the network. + delete_option('beyondwords_api_key'); + delete_option('beyondwords_project_id'); + + $postIds = []; + for ($i = 0; $i < $selected; $i++) { + $postIds[] = self::factory()->post->create(['post_status' => 'publish']); + } + + // Give the last post existing audio so the "needs update" ordering branch + // is exercised — it must be deferred behind the fresh generations. + update_post_meta($postIds[$selected - 1], 'beyondwords_content_id', 'existing-content-id'); + + $counts = Sync::bulk_generate_audio_for_posts($postIds); + + $this->assertSame(Sync::BULK_GENERATE_SYNC_LIMIT, $counts['failed']); + $this->assertSame(0, $counts['generated']); + $this->assertSame($overflow, $counts['deferred']); + + // The already-generated post is ordered last, so it is one of the deferred + // ones and keeps its content ID untouched. + $this->assertSame('existing-content-id', get_post_meta($postIds[$selected - 1], 'beyondwords_content_id', true)); + + // Every selected post is flagged, including the deferred ones. + foreach ($postIds as $postId) { + $this->assertEquals('1', get_post_meta($postId, 'beyondwords_generate_audio', true)); + } + + // The whole point of the cap is that the synchronous batch stays inside a + // typical PHP/VIP execution limit even if every call burns its full + // timeout. Guards against a future change raising either the cap or the + // shared request timeout to an unsafe combination. + $this->assertLessThanOrEqual( + 60, + Sync::BULK_GENERATE_SYNC_LIMIT * \BeyondWords\Api\Client::DEFAULT_REQUEST_TIMEOUT, + 'Worst-case bulk generate (cap x per-call timeout) must stay within a 60s execution limit.' + ); + + foreach ($postIds as $postId) { + wp_delete_post($postId, true); + } + } + + /** + * @test + * @group generateAudio + */ + public function bulk_generate_audio_for_posts_normalises_ids() + { + add_filter('beyondwords_async_generate_audio', '__return_true'); + + $postId = self::factory()->post->create(['post_status' => 'publish']); + + // Duplicates, zero, and negative IDs must be dropped/deduped. + $counts = Sync::bulk_generate_audio_for_posts([$postId, $postId, 0, -5]); + + $this->assertSame(1, $counts['generated']); + $this->assertSame(0, $counts['failed']); + $this->assertSame(0, $counts['deferred']); + $this->assertNotFalse(wp_next_scheduled(Sync::GENERATE_AUDIO_CRON_HOOK, [$postId])); + + wp_unschedule_event(wp_next_scheduled(Sync::GENERATE_AUDIO_CRON_HOOK, [$postId]), Sync::GENERATE_AUDIO_CRON_HOOK, [$postId]); + wp_delete_post($postId, true); + remove_filter('beyondwords_async_generate_audio', '__return_true'); + } + /** * @test * @group generateAudio diff --git a/tests/phpunit/posts-list/test-bulk-edit.php b/tests/phpunit/posts-list/test-bulk-edit.php index b1c43113..5c6baaaa 100644 --- a/tests/phpunit/posts-list/test-bulk-edit.php +++ b/tests/phpunit/posts-list/test-bulk-edit.php @@ -2,6 +2,7 @@ use BeyondWords\PostsList\BulkEdit; use BeyondWords\Core\Plugin; +use BeyondWords\Post\Sync; final class BulkEditTest extends TestCase { @@ -529,4 +530,65 @@ public function handle_bulk_delete_action_is_a_noop_when_no_editable_posts_remai wp_delete_post($otherPostId, true); } + + /** + * @test + * + * @backupGlobals disabled + */ + public function handle_bulk_generate_action_defers_beyond_sync_cap() + { + // The handler gates on current_user_can( 'edit_post', ... ), so run as an + // administrator who can edit every selected post. + wp_set_current_user(self::factory()->user->create(['role' => 'administrator'])); + + // Run off VIP (default) with no API credentials, so the capped posts fail + // without a network call and the overflow is deferred. Select two more + // than the cap and derive the expectations from the constant. + $overflow = 2; + $selected = Sync::BULK_GENERATE_SYNC_LIMIT + $overflow; + + delete_option('beyondwords_api_key'); + delete_option('beyondwords_project_id'); + + $postIds = []; + for ($i = 0; $i < $selected; $i++) { + $postIds[] = self::factory()->post->create(['post_status' => 'publish']); + } + + $nonce = wp_create_nonce('beyondwords_bulk_edit_result'); + $redirect = add_query_arg('beyondwords_bulk_edit_result_nonce', $nonce, 'https://example.com/wp-admin/edit.php'); + + $redirect = BulkEdit::handle_bulk_generate_action($redirect, 'beyondwords_generate_audio', $postIds); + + parse_str((string) parse_url($redirect, PHP_URL_QUERY), $args); + + // Capped posts processed (all failed, no creds) + overflow deferred. + $this->assertSame('0', $args['beyondwords_bulk_generated']); + $this->assertSame((string) Sync::BULK_GENERATE_SYNC_LIMIT, $args['beyondwords_bulk_failed']); + $this->assertSame((string) $overflow, $args['beyondwords_bulk_deferred']); + + foreach ($postIds as $postId) { + $this->assertEquals('1', get_post_meta($postId, 'beyondwords_generate_audio', true)); + } + + foreach ($postIds as $postId) { + wp_delete_post($postId, true); + } + } + + /** + * @test + * + * @backupGlobals disabled + */ + public function handle_bulk_generate_action_ignores_other_actions() + { + $redirect = 'https://example.com/wp-admin/edit.php'; + + // A non-BeyondWords bulk action must be returned untouched. + $result = BulkEdit::handle_bulk_generate_action($redirect, 'trash', [1, 2, 3]); + + $this->assertSame($redirect, $result); + } } diff --git a/tests/phpunit/posts-list/test-notices.php b/tests/phpunit/posts-list/test-notices.php index 47f1d2e8..185a8fca 100644 --- a/tests/phpunit/posts-list/test-notices.php +++ b/tests/phpunit/posts-list/test-notices.php @@ -38,6 +38,7 @@ public function init() do_action('wp_loaded'); $this->assertEquals(10, has_action('admin_notices', array(Notices::class, 'generated_notice'))); + $this->assertEquals(10, has_action('admin_notices', array(Notices::class, 'deferred_notice'))); $this->assertEquals(10, has_action('admin_notices', array(Notices::class, 'deleted_notice'))); $this->assertEquals(10, has_action('admin_notices', array(Notices::class, 'failed_notice'))); $this->assertEquals(10, has_action('admin_notices', array(Notices::class, 'error_notice'))); @@ -105,6 +106,21 @@ public function generated_notice_with_invalid_nonce() Notices::generated_notice(); } + /** + * @test + */ + public function deferred_notice_with_invalid_nonce() + { + set_current_screen('edit-post'); + + $_GET['beyondwords_bulk_edit_result_nonce'] = 'foo'; + $_GET['beyondwords_bulk_deferred'] = '42'; + + $this->expectException(\WPDieException::class); + + Notices::deferred_notice(); + } + /** * @test */ @@ -219,6 +235,48 @@ public function deleted_notice_provider() { ]; } + /** + * @test + * + * @dataProvider deferred_notice_provider + */ + public function deferred_notice($numDeferred, $expectMessage) + { + set_current_screen('edit-post'); + + $_GET['beyondwords_bulk_edit_result_nonce'] = wp_create_nonce('beyondwords_bulk_edit_result'); + $_GET['beyondwords_bulk_deferred'] = $numDeferred; + + $html = $this->capture_output(function () { + Notices::deferred_notice(); + }); + + $crawler = new Crawler($html); + + $notice = $crawler->filter('div'); + + $this->assertEquals('beyondwords-bulk-edit-notice-deferred', $notice->getNode(0)->getAttribute('id')); + $this->assertEquals('notice notice-warning is-dismissible', $notice->getNode(0)->getAttribute('class')); + + $this->assertStringContainsString($expectMessage, $notice->filter('p:first-of-type')->text()); + } + + /** + * + */ + public function deferred_notice_provider() { + return [ + '1 post' => [ + 'numDeferred' => 1, + 'expectMessage' => '1 post still needs audio — run Generate audio again to continue.', + ], + '42 posts' => [ + 'numDeferred' => 42, + 'expectMessage' => '42 posts still need audio — run Generate audio again to continue.', + ], + ]; + } + /** * @test *