Skip to content

async components bypass the plan executor concurrency limit — 429 storm (~36-42 parallel API calls) #225

Open

Description

@SecurityQQ

Symptom

Rendering templates/clients/formula-care-ep5.tsx (12 kling clips, each wrapped in an async DialogueClip that awaits vid.audio.speechRange()) instantly blows through the API rate window (60/min): attempt 2/6 with Retry-After=60s, retry storm from varg.ts submitJob backoff.

Root cause (analyzed, needs verification of exact numbers)

Async components run during resolveLazy, before executePlan — so the executor's concurrency: 3 never applies:

resolveLazy → Promise.all over 12 <DialogueClip>          (no limit)
  └ vid.audio.speechRange()
      └ resolveAudioElement → parent video not resolved
          └ resolveVideoElement → POST /v2/video           (12 parallel)
              └ resolveImageInput → resolveImageElement
                  └ POST /v2/image                         (~12-18 parallel)
      └ transcribeAudio → POST /v2/transcription           (12 parallel)

Total: ~36-42 simultaneous API calls from 20 "declared" generations. 429 retries then re-fill the window as it resets, keeping the storm alive.

ep2/ep3/ep4 never hit this because they don't await .audio inside async components; ep5 is the first one-pass auto-trim template.

Open question (why this issue is on hold)

Need to reconstruct the actual request timeline before fixing: 60/min window should absorb ~36-42 requests unless (a) the window was already partly full, (b) 429 retries multiply the count, or (c) standalone resolvers double-submit shared elements (a1/a2/gym cards are shared by 5 scenes each and the standalone resolve path has no in-flight dedup by cacheKey, unlike renderers' pendingFiles). Suspect (b)+(c) — dedup missing means shared cards submit N times.

Action: pull request logs / gateway logs for the failed run, count actual POSTs per second, confirm the multiplier.

Fix directions discussed (not decided)

  1. Shared semaphore in ResolveContext (preferred): prepare() creates pLimit(options.concurrency ?? 3), standalone resolvers wrap only the network calls (generateImage/Video/Speech/Music, transcription) — not whole resolvers (nested-dependency deadlock otherwise). 429 retries land inside the limited section, killing the retry storm too.
  2. In-flight dedup by cacheKey in standalone resolve path (reuse renderers/dedup.ts withDedup) — shared character cards currently double-submit.
  3. NOT viable: "resolveAudioElement should wait for executePlan" — DialogueClip can't return its tree until cutFrom is known, which depends on generated content; executePlan only starts after the tree exists. Await-in-component must generate during resolveLazy.
  4. Workaround tier: raise API rate limit (rejected as a fix, masks the bug).

Context

Activity

  1. SecurityQQ commented on Jul 21, 2026

    @SecurityQQ
    ContributorAuthor

    Root cause found (production DB analysis) + fix landed

    The numbers: 123 jobs in 7 minutes for ~8 unique requests — 52× the location card, 48× two character cards, ~5× each scene composite. All with idempotency_key = NULL.

    Two compounding bugs:

    1. No in-flight dedup in the standalone resolve path. resolveImageElement / resolveVideoElement etc. in resolve.ts call generateImage/Video directly, bypassing the renderers' withDedup/pendingFiles. The meta?.file check only catches finished resolves — 12 concurrent async components racing through speechRange() → resolveAudioElement → resolveVideoElement → resolveImageElement toward one shared locTrail element all saw meta === undefined and fired parallel generations. makeThenable memoization didn't help because nested elements are resolved via direct function calls, not through the thenable.
    2. No Idempotency-Key on job creation. Every 429 retry in submitJob created a brand-new job, refilling the rate window as it reset — the retry storm sustained itself.

    Fix (commit in PR #223):

    • memoizedElementResolve in resolve.ts — WeakMap-keyed by element identity, wraps all standalone resolvers (image/video/speech/music/talking-head/audio). Graph invariant: one element = one generation, regardless of how many paths lead to it. Meta write-back makes later paths (renderers, executePlan, compile) see the element as pre-resolved. Rejected promises stay memoized (no silent re-spend).
    • computeIdempotencyKey in varg.ts — stable hash of (capability, canonicalized params) sent as Idempotency-Key header; 429/network retries reference the same job. Verified: the API stores it (be_varg_jobs.idempotency_key populated on live run).

    Verification (cheap, real API): reproduced the ep5 dependency shape with flux-schnell + nano-banana-pro/edit (3 async components sharing one nested image, concurrent via resolveLazy): exactly 4 jobs for 4 unique requests, shared image generated once, 0 rate-limit warnings, all jobs carry idempotency keys. Plus unit tests (resolve-dedup.test.ts, varg-idempotency.test.ts).

    Still open (the original hold-question, now lower priority): a concurrency semaphore for the resolveLazy phase. With dedup, unique-call count for ep5-class templates (~20) fits the 60/min window fine; a semaphore only matters for graphs with >60 unique generations. Leaving this issue open for that follow-up, de-escalated.

    API-side follow-up (separate repo): consider enforcing/deduping on Idempotency-Key server-side if not already — client now always sends it.

  2. SecurityQQ commented on Jul 21, 2026

    @SecurityQQ
    ContributorAuthor

    Refactor umbrella for the whole ep5 fix series (including the semaphore and API-side idempotency follow-ups tracked here): #227

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions