feat: telemetry-backed adaptive Livy polling to avoid Fabric 429 throttling - #265
Closed
Raki (mdrakiburrahman) wants to merge 3 commits into
Closed
feat: telemetry-backed adaptive Livy polling to avoid Fabric 429 throttling#265Raki (mdrakiburrahman) wants to merge 3 commits into
Raki (mdrakiburrahman) wants to merge 3 commits into
Conversation
…ttling Fabric's REST front end enforces a unified quota per identity and returns HTTP 429. The adapter polled statements on a fixed 0.3s->1.5s interval per dbt thread, so poll cost grew linearly with `threads` and was blind to how long a model actually takes: a 40-minute model was polled ~1600 times, every poll identical and useless. Backoff was also thread-local, so a 429 seen by one thread left the others hammering and the bucket never refilled. Three changes: - A process-wide throttle governor shared by all Fabric REST calls, with a sliding-window limiter and a shared Retry-After gate, so a 429 parks every thread rather than just the one that saw it. - An elapsed-proportional poll schedule that learns each model's typical runtime, on by default and requiring no configuration. - Opt-in `adaptive_polling`, which drives scheduling from Spark task telemetry read by a monitor REPL. Telemetry is advisory only; completion and failure are still resolved exclusively by authoritative statement GETs. Measured on live Fabric at 6 threads on an identical workload, polling calls dropped from 427 to 143 (3.0x) at the same wall clock. At 6 threads the old schedule already spent 118 calls/min on polling alone. Also fixes three correctness bugs found while working in this code: an ambiguous submit could execute side-effecting SQL twice, `cancel()` was a no-op in both backends, and the singleton poll loop's exponential backoff had no ceiling so a sustained outage could park a run far past `statement_timeout`. Closes #261 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Follow-up hardening on the adaptive polling work for #261. Session-scoped EWMA - Removed all on-disk persistence of learned statement durations. The duration store is now a single in-memory instance that dies with the process and is cleared in cleanup_all(), so nothing is written to the filesystem and no state leaks between runs or identities. - Lowered MIN_SAMPLES_TO_EXTEND from 3 to 1. The old threshold was calibrated for a store that survived across runs; in memory it made a one-sample estimate worse than having no estimate at all (42 vs 39 polls on a 300s statement) because only the shortening branch could apply. At 1 the same case costs 19 polls, and over-estimates stay safe. - Normalised the scheduler bounds so a large poll_statement_wait cannot push min_interval above max_interval. Duplicate-DML safety - A statement listing is now only treated as proof of absence when every entry carries a code we could have scanned; a missing or non-dict entry is inconclusive and refuses the resubmit. - The refusal is raised as AmbiguousSubmissionError and re-raised ahead of every retry classification, so neither the message matcher nor retry_all can turn it back into a resubmit. - connect_retries: 0 now means zero retries instead of falling back to 3. Throttle governor - Routed the shortcut, MLV and lakehouse-properties clients through the shared governor so all Fabric REST traffic draws on one per-identity budget and a 429 anywhere parks the gate everywhere. - Removed the duplicate uninterruptible sleep on the throttled submit path and log long pauses at warning level. - Rejected non-finite Retry-After hints (NaN spun the endpoint, Infinity hung the run forever) and capped them at 120s. Validated against live Fabric: functional suite green in both no_schema and with_schema modes. Unit suite 755 passed, local-e2e green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A `CapacityLimitExceeded` 429 only parks the governor's submit gate, and critical priority deliberately bypasses that gate so in-flight work can still drain. The MLV client had dropped its local back-off for every governed 429, so its two critical-priority callers — `get_job_instance` and `delete_schedule` — retried with no pause at all, firing three back-to-back requests at the API that had just reported capacity exhaustion. Critical MLV callers now serve that wait themselves, mirroring the two Livy poll loops which already handle this case. Plain 429s are unchanged: they park the shared gate, which does apply at critical priority, so those callers must not sleep again. Also promotes `throttle.is_capacity_error` to a public helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.



Closes #261
Why this change is needed
Fabric's REST front end enforces a quota per identity and returns
HTTP 429with aRetry-After. The adapter was a heavy consumer of it, so runs collapsed into sustained throttling and models ran extremely slowly.Two causes:
threads, and blind. Statements were polled on a fixed 0.3s → 1.5s interval per thread. A 40-minute model was polled ~1600 times; a 2-second model got the same schedule.Reproduced live: at 6 threads the old schedule spent 118 calls/min on polling alone. Reported in #261, with analysis in this comment.
How
Retry-Aftergate, so a 429 seen by any thread parks every thread. Newapi_calls_per_minute(default150);0disables the limiter but keeps shared backoff.adaptive_polling(defaultfalse) drives scheduling from Spark task counters read via a monitor REPL, making poll cost constant inthreads. Statements are tagged with a dbt-alignedjobGroup, which also improves Spark UI visibility.Considerations for the reviewer:
GET .../statements/{id}may resolve, complete or fail a statement. "All known jobs terminal" never means done — one SQL statement runs several Spark jobs and the group is empty before the first and between jobs.statement_timeout.progressfield (Fabric's HC statement GET does not return one).Correctness bugs found and fixed along the way — an ambiguous submit could execute DML twice,
cancel()was a no-op, singleton poll backoff was unbounded,NaN/InfinityRetry-Aftercaused a spin/hang, and capacity 429s at critical priority retried with no pause.Test
npx nx run dbt-fabricspark:test --output-style=streamgreen locally, plus full CI (unit → live Fabric functional → local-e2e). 757 unit tests; every regression guard mutation-tested.Heuristics payload — telemetry-driven backoff
One 300s
INSERT, one prior sample this session, jitter pinned to 0.interval = base + elapsed × 0.12, converging on the learned ETA, capped atMAX_INTERVAL:Polls to detect completion, versus the old fixed schedule:
Overshoot stays sub-second because lengthening also requires
elapsed < predicted × 0.85and is capped byLENGTHEN_MULTIPLEandMAX_INTERVAL. A 100,000× over-estimate on a 2s statement still resolves in 2 polls.Live Fabric — 6 threads, identical workload
adaptive_polling: true3.0x fewer calls by default at the same wall clock. The run absorbed 19 real 429s and a
CapacityLimitExceeded; each logged its pause and every model completed. Functional suite passes in bothno_schemaandwith_schemamodes.