Concurrent parallel branch fan-out + terminal-completion hook#397
Merged
Conversation
Add do_action( 'wp_agent_workflow_run_completed', $result, $run_id ) at the single terminal funnel in the runner's step loop. It fires whether a run completes straight through (run()) or via an async resume after its parallel branches reconcile (resume()). This lets an async consumer finalize on completion WITHOUT block-polling the recorder — it can dispatch a fanout, return from its worker immediately (releasing the AS claim), and do its finalization from this hook instead of holding a claim while waiting on the very branches it dispatched. ## AI assistance - **AI assistance:** Yes - **Tool(s):** Claude Opus 4.8 via Claude Code - **Used for:** Locating the terminal funnel and adding the hook.
The AS branch executor enqueued N branches but left them to drain through Action Scheduler's default runner, which runs ONE batch of 25 actions in one worker — so a fan-out's N branches ran serially in a single PHP process instead of N workers each running one branch. Make concurrent branch execution a property of the substrate so every fan-out consumer gets it for free, rather than each hand-rolling it: - dispatch() now triggers the async queue runner itself after enqueuing the branches (self::trigger_async_runner), opening N loopback connections CONCURRENTLY via Requests::request_multiple (curl_multi) so N distinct workers each accept one and claim one branch. Bounded by MAX_BRANCH_ CONCURRENCY (8). Falls back to serial non-blocking POSTs where the parallel Requests API is absent; a no-loopback runtime still drains via AS's own cron path. - A loopback dispatch-URL rewrite points the runner trigger at 127.0.0.1 (carrying the real host in a Host header). curl_multi bypasses WordPress's http_api_curl hook, so a `.local` custom domain would pay a ~5s multicast- DNS timeout per handle and the concurrent handles would serialize; the rewrite lets every handle connect instantly. - A persistent AS concurrency policy (registered in the present-only wiring): concurrent_batches is RAISED to the pending branch count (capped at MAX_BRANCH_CONCURRENCY) and batch_size PINNED to 1 while — and only while — this executor's own branches are pending (gated on pending_branch_count() over BRANCH_HOOK, read live per call). Together they turn N running workers into N distinct branches. On MySQL, each worker's stake_claim uses FOR UPDATE SKIP LOCKED, so concurrent claims pick distinct branches. Blast radius is bounded: with no branches pending both filters pass through unchanged, so other Action Scheduler workloads keep stock behavior. Branches remain ordinary Action Scheduler actions — enqueued, claimed, run, and reconciled through AS. This only triggers AS's async runner concurrently and tunes AS's own concurrency filters; it does not bypass AS. Verified on a live MySQL native-PHP runtime: a multi-page Site Forge fan-out completes end to end with the policy resolving correctly (idle concurrent=1 batch=25; with branches pending concurrent=min(N,8) batch=1) and branches running under the shared substrate, Site Forge holding no concurrency code of its own. ## AI assistance - **AI assistance:** Yes - **Tool(s):** Claude Opus 4.8 via Claude Code - **Used for:** Diagnosing the serial-drain root cause, relocating the concurrency mechanics from a consumer into the substrate, and verifying on the live runtime.
The concurrency policy and dispatch read values from apply_filters() and $_COOKIE, which are `mixed` at PHPStan level max. Normalize each at the point of use — is_numeric()/absint() coercion for the numeric filter values, and $_COOKIE directly (it is always a set array) — instead of blind casts. No behavior change. ## AI assistance - **AI assistance:** Yes - **Tool(s):** Claude Opus 4.8 via Claude Code - **Used for:** Resolving PHPStan level-max findings on the new fan-out code.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Make parallel workflow fan-out complete-able and concurrent as a property of the substrate, so every consumer (Site Forge, wp-codebox, Data Machine) gets it for free instead of hand-rolling it.
Two changes:
Terminal-completion hook.
do_action( 'wp_agent_workflow_run_completed', $result, $run_id )fires at the single terminal funnel in the runner's step loop — whether a run completes straight through (run()) or via an async resume after its parallel branches reconcile (resume()). This lets an async consumer finalize on completion WITHOUT block-polling the recorder: dispatch a fan-out, return from the worker immediately (releasing the AS claim), and finalize from the hook — instead of holding a claim while waiting on the very branches it dispatched (which self-deadlocks a cooperative queue).Concurrent branch execution on Action Scheduler. The branch executor enqueued N branches but left them to drain through AS's default runner (one batch of 25 in one worker), so a fan-out's branches ran serially in one PHP process. Now:
dispatch()triggers the async queue runner itself after enqueuing, opening N loopback connections concurrently (Requests::request_multiple/ curl_multi) so N distinct workers each claim one branch. Bounded byMAX_BRANCH_CONCURRENCY(8); serial fallback where the parallel Requests API is absent.127.0.0.1(real host in aHostheader) — curl_multi bypasses WordPress'shttp_api_curlhook, so a.localcustom domain would otherwise pay a ~5s multicast-DNS timeout per handle and serialize the concurrent handles.concurrent_batchesraised to the pending branch count (capped),batch_sizepinned to 1, only while this executor's own branches are pending (gated live onpending_branch_count()overBRANCH_HOOK). Together they turn N running workers into N distinct branches.Not a bypass of Action Scheduler
Branches stay ordinary AS actions — enqueued, claimed, run, and reconciled through AS. This only triggers AS's async runner concurrently and tunes AS's own concurrency filters.
Blast radius
The concurrency filters are process-global (AS has no per-group knob), but both gate on this executor's pending branches: with none pending they pass their incoming value through unchanged, so other Action Scheduler workloads keep stock behavior (
concurrent=1,batch=25). Theconcurrent_batchesraise only ever raises (max of incoming and target), never lowers another plugin's ceiling.Verification
Verified on a live native-PHP runtime backed by MySQL: a multi-page Site Forge fan-out completes end to end, the policy resolves correctly (idle
concurrent=1 batch=25; with N branches pendingconcurrent=min(N,8) batch=1), and branches run under the shared substrate with the consumer holding no concurrency code of its own. The change is backend-agnostic — it functions on any Action Scheduler store; concurrent claims are simply more effective on MySQL'sFOR UPDATE SKIP LOCKEDthan on SQLite's single-writer model.AI assistance