fix: wake deferred AI step on lease release, cap backoff at 120s, expose lease utilization (#3499) - #3500
Merged
Conversation
…at 120s AI concurrency backpressure starved deferred jobs: each waiter slept on a per-job exponential backoff capped at 600s and nothing woke a waiter when a lease slot was released, so slots sat idle for up to ten minutes while hundreds of continuations waited. Release-side wake-up: when AIStep releases its pipeline AI concurrency lease, wakeEarliestDeferred() pulls the earliest future-scheduled pending datamachine_resume_ai_step action forward to now. Action Scheduler 3.9 has no scheduled-date update API, so the wake schedules the replacement first (unique=false; the still-pending predecessor would block a unique schedule), then cancels the predecessor and repoints the recorded action ID in the generation ownership state. Args, group, and generation are preserved exactly, and beginGeneration() execution-fencing makes even a transient two-live-actions window safe. The wake is best-effort and fully exception-contained so it can never break the releasing job. Due-now pending actions are never selected: the queue is already self-waking. The backoff cap drops from 600s to 120s and becomes filterable via datamachine_ai_concurrency_max_defer_delay, so the cap only bounds recovery when a wake-up is missed. wp datamachine worker status now appends lease utilization (site slots held/limit, per-provider scopes), pending resume action count, and the median/max ai_resume_generation across a bounded sample. Fixes #3499
Drop null-coalesce and is_array/method_exists guards PHPStan proves redundant on the typed utilization and deferred-snapshot arrays, pass the cancelled action ID as a string per ActionScheduler_Store, and realign the deferral assignment block. Refs #3499
ActionScheduler::is_initialized() and the WP_Agent_Consent_Decision accessors are concrete, typed methods; PHPStan proves the guards always true. Remove them so the lint baseline gate is clean. Refs #3499
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.
Summary
AI concurrency backpressure starved deferred jobs: each waiter slept on a per-job exponential backoff (capped at 600s) and nothing woke a waiter when a lease slot was released. A slot freed and the next waiter stayed asleep for up to ten minutes. This PR adds a release-side wake-up, lowers the cap to a filterable 120s, and surfaces lease utilization in
wp datamachine worker status.Fixes #3499
Observed starvation shape (from the #3499 diagnosis, live 2026-09-15)
datamachine_resume_ai_steppending: 791 (791 distinct jobs), defaultpipeline_ai_concurrency_limit=3ai_resume_generationhistogram of waiters: gen 8:93, gen 9:47, gen 10:162, gen 11:432 — 55% parked at the 600s cap2 2 2 2 2 2 1 1 2 2— slots idle while 791 jobs wait6 7 6 7 7 7 7 8 8 8— still not saturated, same cause(Re-checked blog 7 while writing this PR: the backlog has since drained to 0 pending resume actions — consistent with the diagnosis; the fix removes the need to manually raise the limit.)
Behavior change
1. Release-side wake-up (
AIConcurrencyBackpressure::wakeEarliestDeferred())Called from
AIStep::executeStep()'sfinallyimmediately after$ai_concurrency_lease->release()— the precise signal that a held, used slot just freed. Selection rule: one pendingdatamachine_resume_ai_stepaction, earliest-scheduled first, restricted to actions whose scheduled time is still in the future (a waiter that backed off). Due-now pending actions are never selected — the queue is already self-waking there. The store query is bounded (hook + status + date > now, orderby date ASC, per_page 1) throughas_get_scheduled_actions, so no full scan ofactionscheduler_actions.Consistency with the generation/claim protocol. Action Scheduler 3.9 has no scheduled-date update API (verified against the bundled store:
ActionScheduler_wpPostStoreexposessave_action/cancel_actionbut no update), so the wake goes cancel+recreate, in this order:now, with the waiter's exact args and continuation group (continuationGroup()),unique=false— the still-pending predecessor would block a unique schedule.ActionScheduler_Store::instance()->cancel_action().AIConcurrencyBackpressure::repointScheduledAction(), a CAS-style engine-data mutation fenced byflow_step_id + generation + status='scheduled' + action_id == previous. No token is needed because the exact previous action ID is the fence.If step 1 fails, the wake aborts with the predecessor untouched. If step 2 or 3 fails, the waiter temporarily has two live actions — harmless, because
datamachine_resume_ai_step_actionexecution-fences throughbeginGeneration()(only the exactscheduled → runningtransition wins; the duplicate no-ops). The whole wake is wrapped in try/catch(\Throwable) and returns a report array — a failed wake-up can never break the releasing job.2. Lowered, filterable backoff cap
AIStep::AI_CONCURRENCY_MAX_DEFER_DELAY: 600 → 120 seconds.datamachine_ai_concurrency_max_defer_delay(args: value, provider, job_id). The cap now only bounds recovery when a wake-up is missed.AI_CONCURRENCY_MAX_DEFER_AGE(stranded threshold) is unchanged.3.
wp datamachine worker statuslease telemetryNew columns appended at the end of the snapshot (table and JSON; existing keys/order untouched):
ai_lease_site_slots_held/ai_lease_site_limitai_lease_provider_scopesheld/limitasopenai:1/2|anthropic:0/3(empty when unconfigured)ai_resume_pending_actionscountquery)ai_resume_sampled_actions/ sample statsai_resume_generationover a bounded sample (default 500, earliest-scheduled;sample_cappedflags truncation)Implementation: new
PipelineAIConcurrencyLimiter::utilization()(read-only slot-row scan, site limit resolution extracted into a sharedsiteLimit()helper) andAIConcurrencyBackpressure::deferredSnapshot(). Everything is bounded and exception-contained.Judgment calls
finally, notPipelineAIConcurrencyLease::release(). The lease/limiter stay generic slot primitives — no resume-hook knowledge, no injected-callback plumbing.AIConcurrencyBackpressurealready ownsRESUME_HOOKand the generation protocol, and AIStep is the AI-specific orchestrator. The limiter's partial-release path (multi-scope acquire failure) deliberately does not wake: nothing was actually used there, and the deferring job reschedules itself anyway.scheduleContinuation,delaySeconds, …).Files touched
inc/Engine/AI/AIConcurrencyBackpressure.php—wakeEarliestDeferred(),repointScheduledAction(),deferredSnapshot(), scheduler-readiness guardinc/Core/Steps/AI/AIStep.php— cap 600 → 120 + filter, wake on lease releaseinc/Engine/AI/PipelineAIConcurrencyLimiter.php—utilization(),heldSlots(),siteLimit()extractioninc/Cli/Commands/WorkerCommand.php— status snapshot additionstests/ai-step-backpressure-smoke.php— cap/filter/wake assertions (+ missingis_wp_errorstub that was breaking this smoke file onmainpre-existing)tests/ai-concurrency-release-wakeup-smoke.php— new, 43 assertionsVerification
php -lon every touched file — clean.php tests/ai-concurrency-release-wakeup-smoke.php— 43 assertions, 0 failures (wake selection picks earliest future waiter only; no-op with no/due-only waiters; ownership repoint keeps token/generation and stays fenced against duplicate execution; reschedule/cancel failure containment; snapshot count/median/max/capping; limiter utilization incl. provider scopes and release reset).php tests/ai-step-backpressure-smoke.php— 64 assertions, 0 failures (note: this smoke file was already fatally broken onmain— missingis_wp_error()stub at Case 5; fixed here as part of extending it).phpcs --standard=WordPressbefore/after diff on touched files: all new findings on added lines fixed except theMethodNameInvalidcamelCase sniffs, which match the repo-wide existing naming convention. (No repophpcs.xml.distexists, socomposer lintruns a bare default standard that the tab-indented codebase fails wholesale pre-existing; phpcs comparison was done against HEAD baselines instead.)phpunitsuite (e.g.JobLifecycleTransitionTest, which covers the resume protocol) runs in the WP Codebox CI environment (WordPress + MySQL) and will gate this PR; no unit-test coupling to the old 600s cap exists (grepped).Authored by Extra Chill Bot (AI agent) via kimaki minion; not yet human-reviewed.