Skip to content

fix: offload bulk audio generation off the request path#573

Merged
galbus merged 6 commits into
mainfrom
claude/nifty-elion-2d1a3f
Jul 19, 2026
Merged

fix: offload bulk audio generation off the request path#573
galbus merged 6 commits into
mainfrom
claude/nifty-elion-2d1a3f

Conversation

@galbus

@galbus galbus commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Problem

The bulk Generate audio action made one blocking remote API call per
selected post
, inline in the edit.php redirect request
(handle_bulk_generate_action()Sync::generate_audio_for_post()
Client::create_audio()/update_audio()). With Screen Options allowing up
to 999 rows plus select-all, that is an unbounded N+1 HTTP loop in a single
admin request:

  • VIP platform violation — unbounded blocking remote requests on a
    request path.
  • Request killed mid-loop — some posts get audio, the redirect never
    fires, the user sees a blank/timeout page and no notice, and re-running
    duplicates API work.

Unlike the delete path (one batched /content/batch_delete), generate had
no batching and never consulted the existing is_async_generation_enabled()
cron-offload path — so even on VIP the loop ran inline.

Fix

handle_bulk_generate_action() now delegates to a new
Sync::bulk_generate_audio_for_posts(), which dispatches by environment
using the existing is_async_generation_enabled() gate:

Environment Behaviour
VIP (async on) Flag beyondwords_generate_audio meta + schedule one background cron job per post via the existing Cron Control path. Request returns immediately — zero blocking calls.
non-VIP (async off) Stay synchronous, but hard-cap the batch (default 10), process posts still missing audio first, and defer the overflow behind a new admin notice.

Non-VIP uses no scheduled tasks

Cron is used only on VIP (unchanged is_async_generation_enabled()
behaviour), because WP-Cron is unreliable off-VIP. Off-VIP hosts get the
capped-synchronous path instead — this PR introduces no new WP-Cron
dependency
on non-VIP. Hosts that later have reliable cron can opt in via
the existing beyondwords_async_generate_audio filter.

Relationship to #582 (already merged)

An earlier revision of this PR added its own BULK_GENERATE_TIMEOUT (15s)
to pull the bulk loop below the then-30s default. #582 has since collapsed
the timeout constants to Client::DEFAULT_REQUEST_TIMEOUT (3s), so that
constant was removed — at 15s it would have made the bulk path more
permissive than every other call. The bulk loop now simply inherits the
shared 3s default, and src/api/class-client.php is untouched by this PR.

That makes the cap the thing that bounds the total, and it bounds it well:
worst case is now 10 × 3s = 30s, inside a typical PHP/VIP execution
limit (it was 10 × 15s = 150s). A regression test asserts exactly this
invariant, so raising either the cap or the shared timeout into an unsafe
combination fails CI.

Reviewer notes

  • The per-post edit_post capability gate from fix: enforce per-post edit_post capability in bulk audio handlers #572 (ff6f5e8) is
    preserved
    , and runs before the delegation — no audio can be generated
    for posts the user cannot edit. Sync normalises/dedupes/sorts the IDs,
    so the handler's old sort() is gone.
  • On non-VIP, a selection larger than the cap generates the first N and
    shows "X posts still need audio — run Generate audio again to
    continue."
    This is the deliberate trade-off of not scheduling off-VIP.
    Deferred posts are ordered missing-audio-first so re-running converges
    rather than re-updating the same already-done posts.
  • VIP schedules one cron event per post (deduped via wp_next_scheduled),
    consistent with the single-save async path.
  • beyondwords_bulk_deferred is added to both bulk handlers' query-arg
    cleanup lists so a stale notice can't linger across actions.

Verification

  • phpcs (WordPress-VIP-Go, full src/ tree): clean, exit 0, no
    phpcs:ignore added.
  • PHPUnit (worktree src via the tests container): full suite green —
    703 tests, 2258 assertions, 1 multisite-only skip. Includes 8 new
    test cases
    : VIP schedule path, non-VIP cap/defer, ID normalisation, the
    worst-case timeout-budget guard, the deferred notice (+ nonce handling),
    and the redirect deferred-count.
  • Cypress not run per repo guidance — no JS surface changed. The
    posts-list spec (bulk-actions.cy.js) passed on the last full CI run.

Bulk "Generate audio" looped one blocking 30s API call per selected
post inside the edit.php redirect request — an unbounded N+1 that
violates VIP (blocking remote calls on a request path) and can kill
the request mid-loop off-VIP.

handle_bulk_generate_action() now delegates to a new
Sync::bulk_generate_audio_for_posts(), split by environment:

- On VIP (async enabled), flag each post and schedule one background
  cron job per post via the existing Cron Control path, so the request
  returns immediately with no blocking calls.
- Off-VIP (no reliable WP-Cron), stay synchronous but hard-cap the
  batch (beyondwords_bulk_generate_sync_limit, default 10), lower the
  per-call timeout to 15s, process posts still missing audio first,
  and defer the overflow behind a new admin notice.

The API timeout now comes from the beyondwords_api_request_timeout
filter instead of a literal, which is phpcs-clean under the VIP
RemoteRequestTimeout sniff and retires the previous phpcs:ignore.

No scheduled tasks are used off-VIP; cron stays VIP-only via the
existing is_async_generation_enabled() gate.

Verified: phpcs (WordPress-VIP-Go) clean; PHPUnit green including 8
new tests plus the BulkEdit integration tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

…1a3f

Reconciles the bulk-generate offload with main's sibling change
2be1ca0 ("defer audio deletion to VIP cron and shorten DELETE
timeout"), which solved the same class of problem for DELETE and
landed first.

Both branches removed the phpcs:ignore on the request timeout but by
different means: main parametrized Client::build_args()/call_api()
with an explicit $timeout (REQUEST_TIMEOUT / DELETE_TIMEOUT), while
this branch routed it through a beyondwords_api_request_timeout
filter. Main's approach is now trunk, so the filter is dropped and
the bulk path adopts the parameter:

- create_audio() and update_audio() take an optional $timeout
  (defaulting to Client::REQUEST_TIMEOUT), which main had only done
  for the DELETE path.
- Sync::generate_audio_for_post() and update_or_recreate_audio()
  forward a $timeout, so the capped synchronous bulk batch passes
  BULK_GENERATE_TIMEOUT instead of scoping a global filter.

The Sync conflicts were additive and both sides are kept: main's
DELETE_AUDIO_CRON_HOOK and delete_audio_by_ids() alongside this
branch's BULK_GENERATE_* constants and bulk_generate_audio_for_posts().

Verified: full phpcs ruleset clean; full PHPUnit suite passes
(663 tests, 2141 assertions, 1 multisite-only skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@galbus
galbus marked this pull request as ready for review July 19, 2026 07:33
@galbus
galbus requested a review from gouravkhunger July 19, 2026 07:33
galbus and others added 2 commits July 19, 2026 08:37
…1a3f

Integrates main's security fix ff6f5e8 ("enforce per-post edit_post
capability in bulk audio handlers") with this branch's rewrite of
handle_bulk_generate_action().

Main added the per-post capability filter to the original inline
generate loop, which this branch had replaced with a delegation to
Sync::bulk_generate_audio_for_posts(). Resolved by keeping main's
capability gate and empty-selection bail ahead of the delegation, so
the security check still runs before any audio is generated. Main's
sort() and counter init are dropped because Sync now normalises,
dedupes and sorts the IDs itself.

The test file conflicts were both sides adding tests, so main's
version is taken wholesale and this branch's two cases are re-appended.
handle_bulk_generate_action_defers_beyond_sync_cap() now runs as an
administrator, matching how main fixed its own integration tests: the
new capability gate rejects user 0, which would otherwise empty the
selection and skip the cap/defer path under test.

Verified: full phpcs ruleset clean; full PHPUnit suite passes
(667 tests, 2159 assertions, 1 multisite-only skip), including all 15
BulkEdit cases from both branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…1a3f

Only src/post/class-sync.php conflicted, and additively: main's
ffedee9 ("stop register_meta leaking secret post meta over REST")
added the REST_LEGACY_META_KEYS and REST_PRIVATE_META_KEYS constants
in the same block where this branch added BULK_GENERATE_SYNC_LIMIT
and BULK_GENERATE_TIMEOUT. Both sides are kept, grouped as cron hooks,
then bulk-generation tuning, then REST meta registration.

src/api/class-client.php merged cleanly: main's new RENDER_TIMEOUT (3s)
and VOICES_TIMEOUT (8s) sit alongside the $timeout parameters this
branch added to create_audio()/update_audio(). REQUEST_TIMEOUT is still
30s, so BULK_GENERATE_TIMEOUT (15s) remains correctly bounded between
DELETE_TIMEOUT and the default.

Verified: full phpcs ruleset clean; full PHPUnit suite passes
(699 tests, 2229 assertions, 1 multisite-only skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@galbus
galbus marked this pull request as draft July 19, 2026 08:12
galbus and others added 2 commits July 19, 2026 10:46
Main's #582 collapsed the timeout constants: REQUEST_TIMEOUT (30s),
DELETE_TIMEOUT and RENDER_TIMEOUT are gone, replaced by
Client::DEFAULT_REQUEST_TIMEOUT (3s), with VOICES_REQUEST_TIMEOUT (8s)
the only exception.

Git auto-merged this textually but the result was semantically broken:
create_audio(), update_audio(), generate_audio_for_post() and
update_or_recreate_audio() all still defaulted their $timeout parameter
to the deleted Client::REQUEST_TIMEOUT, which fatals at call time, and a
SyncTest assertion referenced it too.

BULK_GENERATE_TIMEOUT (15s) existed only to pull the bulk loop below the
old generous 30s default. With the default now 3s it would instead make
the bulk path five times more permissive than every other call — the
opposite of this branch's intent — so it is removed entirely, along with
the $timeout parameter it was threaded through. Those four signatures
return to matching main, and the bulk loop inherits the 3s default.

BULK_GENERATE_SYNC_LIMIT stays: it bounds the number of blocking calls,
which is the actual N+1 fix and is orthogonal to per-call duration. The
shorter default makes it strictly better — worst case drops from
10 x 15s = 150s to 10 x 3s = 30s, inside a typical execution limit. The
SyncTest assertion is rewritten to guard exactly that invariant
(cap x per-call timeout <= 60s) instead of the removed constant.

Verified: full phpcs ruleset clean; full PHPUnit suite passes
(703 tests, 2240 assertions, 1 multisite-only skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The synchronous bulk-generate cap was filterable, but the plugin is
reducing its filter surface and this one earned its keep mainly as test
scaffolding. BULK_GENERATE_SYNC_LIMIT is now read directly, and the
bulk_generate_sync_limit() wrapper — which existed only to apply the
filter and guard a non-positive override — is removed with it. This PR
now introduces no new filters at all.

Both cap tests previously shrank the limit via that filter. They now
select BULK_GENERATE_SYNC_LIMIT + 2 posts and derive every expectation
from the constant, so they exercise the real cap and keep passing if it
is ever retuned. The Sync test also gains an assertion that the
already-generated post is ordered last and so is genuinely deferred with
its content ID untouched, which the old tiny-cap version could not show.

Verified: full phpcs ruleset clean; full PHPUnit suite passes
(703 tests, 2258 assertions, 1 multisite-only skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@galbus
galbus marked this pull request as ready for review July 19, 2026 09:59
@galbus
galbus merged commit 6c22650 into main Jul 19, 2026
13 checks passed
@galbus
galbus deleted the claude/nifty-elion-2d1a3f branch July 19, 2026 10:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants