Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions src/post/class-sync.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<mixed>|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 {
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -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.
*
Expand Down
28 changes: 11 additions & 17 deletions src/posts-list/class-bulk-edit.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 );
Expand All @@ -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',
Expand Down
34 changes: 34 additions & 0 deletions src/posts-list/class-notices.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ] );
Expand Down Expand Up @@ -58,6 +59,39 @@ public static function generated_notice(): void {
<?php
}

/**
* "N posts still need audio" notice after a Generate Audio bulk action that
* hit the synchronous cap off VIP.
*
* Off VIP we can't offload to WP-Cron, so a large selection is processed up to
* a per-request cap and the rest are deferred. Their generate flag is already
* set, so re-running the action — or selecting the remaining posts — completes
* them.
*/
public static function deferred_notice(): void {
$count = self::get_query_count( 'beyondwords_bulk_deferred' );

if ( null === $count ) {
return;
}

$message = sprintf(
/* translators: %d is replaced with the number of posts not yet processed */
_n(
'%d post still needs audio — run Generate audio again to continue.',
'%d posts still need audio — run Generate audio again to continue.',
$count,
'speechkit'
),
$count
);
?>
<div id="beyondwords-bulk-edit-notice-deferred" class="notice notice-warning is-dismissible">
<p><?php echo esc_html( $message ); ?></p>
</div>
<?php
}

/**
* "Audio was deleted for N posts." notice after a Delete Audio bulk action.
*/
Expand Down
114 changes: 114 additions & 0 deletions tests/phpunit/post/test-sync.php
Original file line number Diff line number Diff line change
Expand Up @@ -1444,6 +1444,120 @@ public function on_add_or_update_post_defers_to_cron_when_async_enabled()
delete_option('beyondwords_project_id');
}

/**
* @test
* @group generateAudio
*/
public function bulk_generate_audio_for_posts_defers_to_cron_when_async_enabled()
{
add_filter('beyondwords_async_generate_audio', '__return_true');

$postIds = [
self::factory()->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
Expand Down
Loading
Loading