Skip to content

feat: telemetry-backed adaptive Livy polling to avoid Fabric 429 throttling - #265

Closed
Raki (mdrakiburrahman) wants to merge 3 commits into
mainfrom
dev/mdrrahman/261
Closed

feat: telemetry-backed adaptive Livy polling to avoid Fabric 429 throttling#265
Raki (mdrakiburrahman) wants to merge 3 commits into
mainfrom
dev/mdrrahman/261

Conversation

@mdrakiburrahman

@mdrakiburrahman Raki (mdrakiburrahman) commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Closes #261

Why this change is needed

Fabric's REST front end enforces a quota per identity and returns HTTP 429 with a Retry-After. The adapter was a heavy consumer of it, so runs collapsed into sustained throttling and models ran extremely slowly.

Two causes:

  1. Poll cost was linear in 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.
  2. Backoff was thread-local. Each 429 slept only the thread that saw it. The other N−1 kept hammering, so the bucket never refilled.

Reproduced live: at 6 threads the old schedule spent 118 calls/min on polling alone. Reported in #261, with analysis in this comment.

How

  • Throttle governor (always on) shared by all Fabric REST calls — Livy, MLV, shortcuts. Sliding-window limiter plus a shared Retry-After gate, so a 429 seen by any thread parks every thread. New api_calls_per_minute (default 150); 0 disables the limiter but keeps shared backoff.
  • Elapsed-proportional poll schedule (always on, no config). Interval grows with elapsed time and with a per-SQL-shape runtime learned during the run, so long models are polled rarely and short ones still return promptly.
  • Opt-in adaptive_polling (default false) drives scheduling from Spark task counters read via a monitor REPL, making poll cost constant in threads. Statements are tagged with a dbt-aligned jobGroup, which also improves Spark UI visibility.

Considerations for the reviewer:

  • Telemetry is advisory only. Only an authoritative 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.
  • Learned runtimes are session-scoped and in-memory. Nothing is written to disk, nothing is keyed to an identity, and the store is cleared on cleanup.
  • Retries never resubmit side-effecting SQL, and no wait is unbounded — everything respects statement_timeout.
  • Alternatives rejected: a fixed longer interval (penalises short models), and Livy's progress field (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/Infinity Retry-After caused a spin/hang, and capacity 429s at critical priority retried with no pause.

Test

npx nx run dbt-fabricspark:test --output-style=stream green 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 at MAX_INTERVAL:

 elapsed  interval  reason            telemetry (tasks done/total)
     0.0      0.25  initial-probe     —
     0.2      2.12  learned-eta       0/400
     2.4      3.14  learned-eta       3/400
     5.5      4.64  learned-eta       7/400
    10.2      6.87  learned-eta      13/400
    17.0     10.17  learned-eta      22/400
    27.2     15.05  learned-eta      36/400
    42.2     22.28  learned-eta      56/400
    64.5     30.00  learned-eta      86/400   <- MAX_INTERVAL

Polls to detect completion, versus the old fixed schedule:

statement   legacy   cold (no estimate)   warm (1 sample)   overshoot
       2s        5                    4                 4      0.24s
      20s       19                   16                 8      0.18s
     120s       85                   31                13      0.16s
     300s      205                   39                19      0.16s
    2400s     1605                  109                89      0.16s

Overshoot stays sub-second because lengthening also requires elapsed < predicted × 0.85 and is capped by LENGTHEN_MULTIPLE and MAX_INTERVAL. A 100,000× over-estimate on a 2s statement still resolves in 2 polls.

Live Fabric — 6 threads, identical workload

Mode Wall clock API calls calls/min
Before 217.7s 427 118
After (default) 233.3s 143 37
After, adaptive_polling: true 228.9s 129 34

3.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 both no_schema and with_schema modes.

Raki (mdrakiburrahman) and others added 3 commits August 9, 2026 15:22
…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>
@mdrakiburrahman

Raki (mdrakiburrahman) commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Benchmark vs Privy:

Privy

#265

image image

HC Livy with this PR

It did not help at all:

image

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

Labels

None yet

Projects

None yet

1 participant